Initial Commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# undo-spec-conformance
|
||||
|
||||
- **Change ID**: undo-spec-conformance
|
||||
- **Package**: `@suaveplan/undo`
|
||||
- **Status**: PROPOSED
|
||||
- **Type**: spec-conformance
|
||||
- **Date**: 2026-04-26
|
||||
|
||||
## Why
|
||||
|
||||
The folded `openspec/specs/undo/spec.md` documented requirements as a flat
|
||||
`R01`-`R20` bullet list under `### ADDED Requirements`. The conformance
|
||||
parser only recognises `### Requirement: <name>` or `### CAP-NN —` headings,
|
||||
so `bun scripts/openspec-conformance.ts @suaveplan/undo` reported "0
|
||||
requirements parsed". This is purely a parsing gap; the implementation
|
||||
already satisfies every clause and is pinned by an extensive test suite.
|
||||
|
||||
## What changes
|
||||
|
||||
- Rewrite `openspec/specs/undo/spec.md` from `R01`-`R20` bullets to nine
|
||||
`### Requirement: <name>` blocks (sync UndoStack, BranchingHistory,
|
||||
AsyncUndoStack, HistoryCompressor, SelectiveUndoStack, CommandMerger,
|
||||
command primitives, error codes / UndoError, option schemas).
|
||||
- Add `**Verified by:**` lines under every requirement naming concrete
|
||||
`it(...)` titles in:
|
||||
- `src/stack/stack.test.ts`
|
||||
- `src/branch/branch.test.ts`
|
||||
- `src/async/async-stack.test.ts`
|
||||
- `src/async/async-command.test.ts`
|
||||
- `src/compression/history-compressor.test.ts`
|
||||
- `src/selective/selective-undo.test.ts`
|
||||
- `src/merge/command-merger.test.ts`
|
||||
- `src/command/command.test.ts`
|
||||
- `src/errors/undo-error.test.ts`
|
||||
- `src/schemas/undo.schemas.test.ts`
|
||||
- No source changes; no public-surface changes; no new tests required.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New behaviour. Heading rewrite + verification annotation only.
|
||||
- Source mapping table — `@suaveplan/undo` is genesis-original.
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement is pinned to existing `it(...)` titles across the ten
|
||||
test files listed above (combined: 130+ cases pre-dating this change).
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive metadata only. Public surface unchanged.
|
||||
@@ -0,0 +1,278 @@
|
||||
# @suaveplan/undo — Specification
|
||||
|
||||
**Package path:** `control-flow/undo`
|
||||
**Version:** `0.1.0`
|
||||
**Status:** Shipped 2026-04-24. Spec heading conformance: `undo-spec-conformance` (2026-04-26).
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Enterprise-grade undo/redo engine for state management. Supports synchronous linear stacks, asynchronous stacks, branching history (alternate timelines), selective undo by command ID, history compression, and command merging.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Synchronous UndoStack with bounded history and transactions
|
||||
|
||||
The package SHALL export `UndoStack<T>` providing synchronous linear undo/redo with optional `maxEntries` and `maxMemoryBytes` trimming, EventBus integration, and transaction support (`beginTransaction` / `commit` / `rollback`). Operations on an empty stack MUST throw `UndoError(UNDO.EMPTY_STACK)`. Nested transactions MUST throw `UndoError(UNDO.TRANSACTION_ACTIVE)`; operations on a closed transaction MUST throw `UndoError(UNDO.TRANSACTION_INACTIVE)`.
|
||||
|
||||
#### Scenario: Push / undo / redo round-trips state
|
||||
|
||||
- **WHEN** commands are pushed, then undone, then redone
|
||||
- **THEN** state matches the pre-undo / post-redo expectation at each step
|
||||
- **AND** push clears the redo stack; redo size equals the redo-stack depth
|
||||
|
||||
#### Scenario: Empty-stack operations throw UNDO.EMPTY_STACK
|
||||
|
||||
- **WHEN** `undo()` or `redo()` is called with nothing to undo / redo
|
||||
- **THEN** `UndoError(UNDO.EMPTY_STACK)` is thrown
|
||||
|
||||
#### Scenario: maxEntries and maxMemoryBytes trim the history
|
||||
|
||||
- **WHEN** more than `maxEntries` are pushed (or memory exceeds `maxMemoryBytes`)
|
||||
- **THEN** the oldest entries are trimmed AND `onTruncate` is invoked with the removed entries
|
||||
|
||||
#### Scenario: Transactions are atomic and disallow nesting
|
||||
|
||||
- **WHEN** `beginTransaction()` is called and commands are added then committed
|
||||
- **THEN** all commands apply atomically; rollback discards them
|
||||
- **WHEN** `beginTransaction()` is called twice without commit/rollback
|
||||
- **THEN** `UndoError(UNDO.TRANSACTION_ACTIVE)` is thrown
|
||||
- **WHEN** `commit()` or `add()` is called after the transaction is closed
|
||||
- **THEN** `UndoError(UNDO.TRANSACTION_INACTIVE)` is thrown
|
||||
|
||||
#### Scenario: clear() preserves current state but drops history
|
||||
|
||||
- **WHEN** `clear()` is called
|
||||
- **THEN** undoSize and redoSize are 0 AND the current state is unchanged
|
||||
|
||||
**Verified by:** `src/stack/stack.test.ts::"starts with initial state"` AND `src/stack/stack.test.ts::"push increments undoSize"` AND `src/stack/stack.test.ts::"push clears redo stack"` AND `src/stack/stack.test.ts::"undo reverses command"` AND `src/stack/stack.test.ts::"redo re-applies command"` AND `src/stack/stack.test.ts::"undo then redo round-trip"` AND `src/stack/stack.test.ts::"throws EMPTY_STACK on undo when empty"` AND `src/stack/stack.test.ts::"throws EMPTY_STACK on redo when empty"` AND `src/stack/stack.test.ts::"truncates to maxEntries"` AND `src/stack/stack.test.ts::"calls onTruncate with removed entries"` AND `src/stack/stack.test.ts::"trims entries that exceed memory limit"` AND `src/stack/stack.test.ts::"calls onTruncate when over memory limit"` AND `src/stack/stack.test.ts::"stops trimming once under memory limit (break branch)"` AND `src/stack/stack.test.ts::"commit applies all commands in order"` AND `src/stack/stack.test.ts::"rollback discards all pending commands"` AND `src/stack/stack.test.ts::"throws TRANSACTION_ACTIVE on nested beginTransaction"` AND `src/stack/stack.test.ts::"throws TRANSACTION_INACTIVE on add after commit"` AND `src/stack/stack.test.ts::"throws TRANSACTION_INACTIVE on commit after rollback"` AND `src/stack/stack.test.ts::"allows new transaction after commit"` AND `src/stack/stack.test.ts::"clear removes history but keeps current"`
|
||||
|
||||
### Requirement: BranchingHistory for alternate timelines
|
||||
|
||||
The package SHALL export `BranchingHistory<T>`. With `branchOnDiverge: true` (default), pushing a command after one or more undo operations MUST fork a new branch preserving the redo entries on the old branch. With `branchOnDiverge: false` the redo entries are discarded as in `UndoStack`. Switching to a non-existent branch MUST throw `UndoError(UNDO.AT_BRANCH_ROOT)`. Deleting the active branch MUST throw `UndoError(UNDO.AT_BRANCH_ROOT)`.
|
||||
|
||||
#### Scenario: Diverging push forks a new branch
|
||||
|
||||
- **WHEN** a push happens after one or more undos with `branchOnDiverge: true`
|
||||
- **THEN** a new branch is created AND the original branch's redo entries are preserved on the prior branch
|
||||
|
||||
#### Scenario: branchOnDiverge: false discards redo entries
|
||||
|
||||
- **WHEN** a push happens after undo with `branchOnDiverge: false`
|
||||
- **THEN** the redo entries are dropped (no fork)
|
||||
|
||||
#### Scenario: Branch lookup and deletion
|
||||
|
||||
- **WHEN** `getBranch(id)` is called with a known id
|
||||
- **THEN** the branch is returned
|
||||
- **WHEN** called with an unknown id
|
||||
- **THEN** `undefined` is returned
|
||||
- **WHEN** `deleteBranch(id)` is called for a non-active branch
|
||||
- **THEN** the branch is removed
|
||||
- **WHEN** `deleteBranch(id)` is called for the active branch OR with an unknown id while switching
|
||||
- **THEN** `UndoError(UNDO.AT_BRANCH_ROOT)` is thrown with `branchId` in context
|
||||
|
||||
**Verified by:** `src/branch/branch.test.ts::"creates a new branch when pushing with redo entries"` AND `src/branch/branch.test.ts::"does NOT fork when branchOnDiverge is false"` AND `src/branch/branch.test.ts::"forked branch preserves redo entries"` AND `src/branch/branch.test.ts::"switches to another branch and returns its current"` AND `src/branch/branch.test.ts::"throws AT_BRANCH_ROOT for unknown branchId"` AND `src/branch/branch.test.ts::"includes branchId in error context"` AND `src/branch/branch.test.ts::"returns the branch for a valid id"` AND `src/branch/branch.test.ts::"returns undefined for unknown id"` AND `src/branch/branch.test.ts::"removes a non-active branch"` AND `src/branch/branch.test.ts::"throws AT_BRANCH_ROOT when deleting active branch"` AND `src/branch/branch.test.ts::"uses injected clock for createdAt"`
|
||||
|
||||
### Requirement: AsyncUndoStack mirrors the synchronous surface
|
||||
|
||||
The package SHALL export `AsyncUndoStack<T>` providing async `push` / `undo` / `redo` / transactions with the same semantics as `UndoStack`. Command execution failures MUST be wrapped in `UndoError(UNDO.COMMAND_FAILED)`. The same `EMPTY_STACK`, `TRANSACTION_ACTIVE`, `TRANSACTION_INACTIVE` codes apply. Lifecycle events `undo:push`, `undo:undo`, `undo:redo`, `undo:clear` MUST be emitted on the configured EventBus.
|
||||
|
||||
#### Scenario: Async push / undo / redo round-trip
|
||||
|
||||
- **WHEN** async commands are pushed, undone, and redone
|
||||
- **THEN** state and stack sizes match the synchronous semantics
|
||||
|
||||
#### Scenario: COMMAND_FAILED wraps async execution failures
|
||||
|
||||
- **WHEN** an async command throws during execute
|
||||
- **THEN** the rejection is wrapped in `UndoError(UNDO.COMMAND_FAILED)`
|
||||
|
||||
#### Scenario: Transactions and EMPTY_STACK error codes
|
||||
|
||||
- **WHEN** transaction lifecycle is misused (nested open, commit after rollback)
|
||||
- **THEN** the corresponding `TRANSACTION_*` error codes are thrown
|
||||
|
||||
#### Scenario: EventBus emissions
|
||||
|
||||
- **WHEN** push / undo / redo / clear / reset happens
|
||||
- **THEN** the corresponding `undo:*` event is emitted
|
||||
|
||||
**Verified by:** `src/async/async-stack.test.ts::"starts with initial state"` AND `src/async/async-stack.test.ts::"push increments undoSize"` AND `src/async/async-stack.test.ts::"push clears redo stack"` AND `src/async/async-stack.test.ts::"undo reverses state"` AND `src/async/async-stack.test.ts::"redo re-applies command"` AND `src/async/async-stack.test.ts::"throws EMPTY_STACK on undo when empty"` AND `src/async/async-stack.test.ts::"throws EMPTY_STACK on redo when empty"` AND `src/async/async-stack.test.ts::"round-trips push/undo/redo"` AND `src/async/async-stack.test.ts::"redoSize reflects redo stack depth"` AND `src/async/async-stack.test.ts::"activeBranchId is always main"` AND `src/async/async-stack.test.ts::"canUndo is false initially and true after push"` AND `src/async/async-stack.test.ts::"wraps push failure in COMMAND_FAILED"` AND `src/async/async-stack.test.ts::"commit applies all commands"` AND `src/async/async-stack.test.ts::"rollback discards pending"` AND `src/async/async-stack.test.ts::"throws TRANSACTION_ACTIVE on nested open"` AND `src/async/async-stack.test.ts::"throws TRANSACTION_INACTIVE after commit"` AND `src/async/async-stack.test.ts::"throws TRANSACTION_INACTIVE on commit after rollback"` AND `src/async/async-stack.test.ts::"emits undo:push"` AND `src/async/async-stack.test.ts::"emits undo:undo"` AND `src/async/async-stack.test.ts::"emits undo:redo"` AND `src/async/async-stack.test.ts::"emits undo:clear on clear"` AND `src/async/async-stack.test.ts::"emits undo:clear on reset"` AND `src/async/async-stack.test.ts::"clear keeps current but clears history"` AND `src/async/async-stack.test.ts::"reset changes current and clears history"` AND `src/async/async-stack.test.ts::"snapshot returns correct shape"`
|
||||
|
||||
### Requirement: HistoryCompressor squashes old entries
|
||||
|
||||
The package SHALL export `HistoryCompressor<T>` and `compressHistory` which squash entries older than `compressionDepth` (default 50) by label into composite commands and respect `maxHistorySize` by dropping the oldest entries. `compressionDepth: 0` is valid and means "compress all entries". Re-compressing an already-compressed stack MUST be idempotent (no further state change).
|
||||
|
||||
#### Scenario: Within-depth entries are not squashed
|
||||
|
||||
- **WHEN** all entries are within `compressionDepth`
|
||||
- **THEN** the stack is unchanged
|
||||
|
||||
#### Scenario: Old same-label entries are squashed
|
||||
|
||||
- **WHEN** older entries share a label
|
||||
- **THEN** they are combined into a single composite command
|
||||
- **WHEN** older entries have different labels
|
||||
- **THEN** they are not squashed
|
||||
|
||||
#### Scenario: maxHistorySize drops oldest entries
|
||||
|
||||
- **WHEN** the resulting history exceeds `maxHistorySize`
|
||||
- **THEN** the oldest entries are dropped
|
||||
|
||||
#### Scenario: Compression preserves current state and is idempotent
|
||||
|
||||
- **WHEN** `compress` is called
|
||||
- **THEN** the resulting current state equals the input current state
|
||||
- **AND** running `compress` again does not change the result
|
||||
|
||||
**Verified by:** `src/compression/history-compressor.test.ts::"returns compressionDepth getter"` AND `src/compression/history-compressor.test.ts::"returns maxHistorySize getter"` AND `src/compression/history-compressor.test.ts::"defaults compressionDepth to 50"` AND `src/compression/history-compressor.test.ts::"compress delegates to compressHistory"` AND `src/compression/history-compressor.test.ts::"compress with maxHistorySize drops old entries"` AND `src/compression/history-compressor.test.ts::"is a no-op when all entries within depth"` AND `src/compression/history-compressor.test.ts::"squashes old entries with same label"` AND `src/compression/history-compressor.test.ts::"preserves current state after compression"` AND `src/compression/history-compressor.test.ts::"respects maxHistorySize by dropping old entries"` AND `src/compression/history-compressor.test.ts::"handles empty stack"` AND `src/compression/history-compressor.test.ts::"does not squash entries with different labels"` AND `src/compression/history-compressor.test.ts::"CompressionResult has correct shape"` AND `src/compression/history-compressor.test.ts::"returns stack as UndoStack instance"` AND `src/compression/history-compressor.test.ts::"re-compressing does not change state"` AND `src/compression/history-compressor.test.ts::"compressed stack has same or fewer entries than original"` AND `src/schemas/undo.schemas.test.ts::"accepts zero compressionDepth (means compress everything)"`
|
||||
|
||||
### Requirement: SelectiveUndoStack with dependency-aware undo by ID
|
||||
|
||||
The package SHALL export `SelectiveUndoStack<T>` extending `UndoStack<T>` with `undoById(commandId)` (re-applies later commands after the targeted undo), dependency checking, and conflict detection. With `throwOnConflict: false` (default), conflicts return `{ success: false, conflictIds }`; with `throwOnConflict: true`, conflicts throw `UndoError(UNDO.DEPENDENCY_CONFLICT)` with `conflictIds` in context.
|
||||
|
||||
#### Scenario: Selective undo of a non-conflicting command preserves later commands
|
||||
|
||||
- **WHEN** `undoById(id)` is called on a command with no dependents
|
||||
- **THEN** the targeted command is undone AND later commands are re-applied
|
||||
|
||||
#### Scenario: Conflicts surface as result or thrown error
|
||||
|
||||
- **WHEN** dependents exist and `throwOnConflict` is `false`
|
||||
- **THEN** the result is `{ success: false, conflictIds: [...] }`
|
||||
- **WHEN** dependents exist and `throwOnConflict` is `true`
|
||||
- **THEN** `UndoError(UNDO.DEPENDENCY_CONFLICT)` is thrown with `conflictIds` in context
|
||||
|
||||
#### Scenario: Unknown command id is reported, not crashed
|
||||
|
||||
- **WHEN** `undoById` is called with an unknown id
|
||||
- **THEN** the result is `{ success: false }` (no throw)
|
||||
|
||||
**Verified by:** `src/selective/selective-undo.test.ts::"undoes first command and re-applies later ones"` AND `src/selective/selective-undo.test.ts::"returns success: false for unknown id"` AND `src/selective/selective-undo.test.ts::"returns conflictIds when dependency exists (no throw)"` AND `src/selective/selective-undo.test.ts::"throws DEPENDENCY_CONFLICT when throwOnConflict is true"` AND `src/selective/selective-undo.test.ts::"includes conflictIds in error context"` AND `src/selective/selective-undo.test.ts::"preserves later commands after selective undo"` AND `src/selective/selective-undo.test.ts::"undoes the most recent command"` AND `src/selective/selective-undo.test.ts::"returns empty for no dependents"` AND `src/selective/selective-undo.test.ts::"returns dependent IDs"` AND `src/selective/selective-undo.test.ts::"returns empty for unknown id"` AND `src/selective/selective-undo.test.ts::"returns success: true when no conflicts"` AND `src/selective/selective-undo.test.ts::"returns success: false with conflictIds when conflicts exist"`
|
||||
|
||||
### Requirement: CommandMerger coalesces compatible consecutive commands
|
||||
|
||||
The package SHALL export `CommandMerger<T>` that buffers consecutive commands matching a `MergePredicate` (default: same label) and flushes them as one composite command when the count limit is reached, the time window expires, or a non-compatible command arrives. Manual `flush()` MUST also work; `reset()` discards pending without flushing. The merged command MUST produce the same final state as executing the constituents individually.
|
||||
|
||||
#### Scenario: Buffer accumulates and flushes on count limit
|
||||
|
||||
- **WHEN** `add` is called up to `maxCount` times with compatible commands
|
||||
- **THEN** `flush` returns one composite command
|
||||
|
||||
#### Scenario: Buffer flushes on time-window expiry
|
||||
|
||||
- **WHEN** the time window expires after compatible commands are added
|
||||
- **THEN** they are flushed automatically
|
||||
|
||||
#### Scenario: Buffer flushes when an incompatible command arrives
|
||||
|
||||
- **WHEN** a command with a different label is added
|
||||
- **THEN** the existing buffer is flushed first
|
||||
|
||||
#### Scenario: Custom predicate overrides default
|
||||
|
||||
- **WHEN** a custom `MergePredicate` is supplied
|
||||
- **THEN** the buffer respects that rule rather than the same-label default
|
||||
|
||||
#### Scenario: Merged command equals individual execution
|
||||
|
||||
- **WHEN** the merged command is executed
|
||||
- **THEN** the resulting state equals the state from executing the constituents individually
|
||||
|
||||
**Verified by:** `src/merge/command-merger.test.ts::"hasPending is false initially"` AND `src/merge/command-merger.test.ts::"pendingCount is 0 initially"` AND `src/merge/command-merger.test.ts::"add accumulates without flushing"` AND `src/merge/command-merger.test.ts::"flush returns merged command"` AND `src/merge/command-merger.test.ts::"flush returns undefined when nothing pending"` AND `src/merge/command-merger.test.ts::"flush of single command returns it directly"` AND `src/merge/command-merger.test.ts::"reset discards pending"` AND `src/merge/command-merger.test.ts::"flushes when count limit reached"` AND `src/merge/command-merger.test.ts::"merged command executes correctly"` AND `src/merge/command-merger.test.ts::"flushes when time window expires"` AND `src/merge/command-merger.test.ts::"returns true for same-label commands (default predicate)"` AND `src/merge/command-merger.test.ts::"returns false for different labels"` AND `src/merge/command-merger.test.ts::"uses custom predicate"` AND `src/merge/command-merger.test.ts::"flushes buffer when new command has different label"` AND `src/merge/command-merger.test.ts::"merged command produces same state as executing individually"`
|
||||
|
||||
### Requirement: Command primitives — sync and async factories, composition, patches, size estimation
|
||||
|
||||
The package SHALL export `createCommand<T>` (sync closure factory with injectable `clock` / `idGenerator`), `createAsyncCommand<T>` (async factory; rejections wrapped in `UndoError(UNDO.COMMAND_FAILED)`), `createPatchCommand<T>` (diff old/new state via `@suaveplan/diff` to produce a reversible patch command), `composeCommands<T>` and `composeAsyncCommands<T>` (atomic composition with reverse-order undo), `executeCommand<T>` / `undoCommand<T>` (delegate to `command.execute(state)` / `command.undo(state)`), and `estimateCommandSize<T>` (JSON-serialization based, with fallback to id+label length for non-serializable metadata).
|
||||
|
||||
#### Scenario: Sync factory honours clock and idGenerator
|
||||
|
||||
- **WHEN** `createCommand({ clock, idGenerator, label, metadata })` is called
|
||||
- **THEN** the command has a unique id, the timestamp from the clock, and the label/metadata preserved
|
||||
- **AND** the default clock is `Date.now`
|
||||
|
||||
#### Scenario: Async factory wraps execute/undo errors
|
||||
|
||||
- **WHEN** an async command's execute or undo rejects
|
||||
- **THEN** the rejection is wrapped in `UndoError(UNDO.COMMAND_FAILED)` (Error or non-Error rejection)
|
||||
- **WHEN** an async command rejects with an existing `UndoError`
|
||||
- **THEN** it is passed through without double-wrapping
|
||||
|
||||
#### Scenario: Composition runs in order forward, reverse on undo
|
||||
|
||||
- **WHEN** `composeCommands([a, b, c])` is executed
|
||||
- **THEN** commands run in `[a, b, c]` order
|
||||
- **WHEN** undone
|
||||
- **THEN** commands undo in `[c, b, a]` order
|
||||
- **WHEN** the input array is empty
|
||||
- **THEN** the composite command is a no-op
|
||||
|
||||
#### Scenario: Patch command round-trips through diff
|
||||
|
||||
- **WHEN** `createPatchCommand(oldState, newState)` is built and executed
|
||||
- **THEN** the new state is produced; undoing restores the old state
|
||||
- **AND** the round-trip works for arbitrary JSON-serializable state
|
||||
|
||||
#### Scenario: Size estimator returns positive bytes with fallback
|
||||
|
||||
- **WHEN** `estimateCommandSize` is called for a closure or patch command
|
||||
- **THEN** a positive number is returned
|
||||
- **WHEN** metadata is non-serializable
|
||||
- **THEN** the fallback uses id + label length
|
||||
|
||||
**Verified by:** `src/command/command.test.ts::"sets a unique id"` AND `src/command/command.test.ts::"sets timestamp from clock"` AND `src/command/command.test.ts::"preserves label"` AND `src/command/command.test.ts::"preserves metadata"` AND `src/command/command.test.ts::"defaults clock to Date.now"` AND `src/command/command.test.ts::"executes commands in order"` AND `src/command/command.test.ts::"undoes commands in reverse order"` AND `src/command/command.test.ts::"handles empty command array"` AND `src/command/command.test.ts::"creates a new unique id"` AND `src/command/command.test.ts::"applies execute to state"` AND `src/command/command.test.ts::"applies undo to state"` AND `src/command/command.test.ts::"round-trips execute then undo"` AND `src/command/command.test.ts::"creates a PatchCommand with type=patch"` AND `src/command/command.test.ts::"executes to produce new state"` AND `src/command/command.test.ts::"undoes to restore old state"` AND `src/command/command.test.ts::"round-trips for complex objects"` AND `src/command/command.test.ts::"accepts metadata"` AND `src/command/command.test.ts::"uses injected clock and idGenerator"` AND `src/command/command.test.ts::"returns a positive number for closure command"` AND `src/command/command.test.ts::"returns a positive number for patch command"` AND `src/command/command.test.ts::"patch command size >= closure command size"` AND `src/command/command.test.ts::"falls back to id+label length when metadata is not serializable"` AND `src/command/command.test.ts::"round-trip for any JSON-serializable state"` AND `src/async/async-command.test.ts::"sets id and label"` AND `src/async/async-command.test.ts::"uses injected clock and idGenerator"` AND `src/async/async-command.test.ts::"preserves metadata"` AND `src/async/async-command.test.ts::"wraps execute errors in UndoError COMMAND_FAILED"` AND `src/async/async-command.test.ts::"wraps undo errors in UndoError COMMAND_FAILED"` AND `src/async/async-command.test.ts::"wraps non-Error rejections"` AND `src/async/async-command.test.ts::"executes in order"` AND `src/async/async-command.test.ts::"undoes in reverse order"` AND `src/async/async-command.test.ts::"handles empty commands"` AND `src/async/async-command.test.ts::"applies command"` AND `src/async/async-command.test.ts::"wraps non-UndoError rejection"` AND `src/async/async-command.test.ts::"passes through UndoError without double-wrapping"` AND `src/async/async-command.test.ts::"reverses command"` AND `src/async/async-command.test.ts::"passes through UndoError from undo without re-wrapping"` AND `src/async/async-command.test.ts::"wraps non-UndoError from undo"` AND `src/async/async-command.test.ts::"wraps a raw non-UndoError rejection"` AND `src/async/async-command.test.ts::"wraps a non-Error (string) rejection from executeAsyncCommand"` AND `src/async/async-command.test.ts::"wraps a non-Error (string) rejection from undoAsyncCommand"` AND `src/async/async-command.test.ts::"wraps non-Error thrown from undo"`
|
||||
|
||||
### Requirement: Stable error-code registry and structured UndoError
|
||||
|
||||
The package SHALL export `UNDO_CODES` registered via `defineCodes` exposing the six codes `EMPTY_STACK`, `AT_BRANCH_ROOT`, `DEPENDENCY_CONFLICT`, `COMMAND_FAILED`, `TRANSACTION_ACTIVE`, `TRANSACTION_INACTIVE`, all under the `UNDO.*` namespace. `UndoError` SHALL extend `SuaveplanError<UndoCode>` with structured `UndoErrorContext` (`commandId`, `branchId`, `conflictIds`).
|
||||
|
||||
#### Scenario: Six codes, all unique, all UNDO.*
|
||||
|
||||
- **WHEN** `UNDO_CODES` is inspected
|
||||
- **THEN** there are exactly six codes, all values are unique, all start with `UNDO.`
|
||||
|
||||
#### Scenario: UndoError carries SuaveplanError shape and context
|
||||
|
||||
- **WHEN** an `UndoError` is constructed
|
||||
- **THEN** it is `instanceof UndoError` AND `instanceof SuaveplanError`
|
||||
- **AND** `name` is `UndoError`
|
||||
- **AND** `code`, `message`, `cause`, and context fields (`commandId`, `branchId`, `conflictIds`) are preserved
|
||||
- **AND** instances can be discriminated by code across all six codes
|
||||
|
||||
**Verified by:** `src/errors/undo-error.test.ts::"has all six codes defined"` AND `src/errors/undo-error.test.ts::"all code values are unique"` AND `src/errors/undo-error.test.ts::"all codes start with UNDO. prefix"` AND `src/errors/undo-error.test.ts::"is an instance of SuaveplanError"` AND `src/errors/undo-error.test.ts::"is an instance of UndoError"` AND `src/errors/undo-error.test.ts::"sets name to UndoError"` AND `src/errors/undo-error.test.ts::"preserves code on the error instance"` AND `src/errors/undo-error.test.ts::"preserves message"` AND `src/errors/undo-error.test.ts::"preserves cause"` AND `src/errors/undo-error.test.ts::"carries commandId context"` AND `src/errors/undo-error.test.ts::"carries branchId context"` AND `src/errors/undo-error.test.ts::"carries conflictIds context"` AND `src/errors/undo-error.test.ts::"can be constructed with no options"` AND `src/errors/undo-error.test.ts::"discriminates by code across all codes"`
|
||||
|
||||
### Requirement: Strict Zod option schemas at every boundary
|
||||
|
||||
The package SHALL export strict Zod schemas for every option type — `UndoStackOptionsSchema`, `BranchingOptionsSchema`, `HistoryCompressorOptionsSchema`, `CommandMergerOptionsSchema`, `SelectiveUndoOptionsSchema` — that reject unknown keys, negative or non-integer counts, zero counts where positive integers are required, and non-function `clock` values, while accepting valid configurations.
|
||||
|
||||
#### Scenario: Each schema accepts an empty object and a fully populated valid object
|
||||
|
||||
- **WHEN** any of the schemas is parsed against `{}` or a valid configuration
|
||||
- **THEN** the parse succeeds
|
||||
|
||||
#### Scenario: Negative / zero / non-integer numeric fields are rejected
|
||||
|
||||
- **WHEN** numeric fields like `maxEntries`, `maxMemoryBytes`, `compressionDepth`, `maxHistorySize`, `maxCount`, `timeWindowMs` are negative, zero (where positive is required), or non-integer
|
||||
- **THEN** the parse fails
|
||||
|
||||
#### Scenario: Unknown keys are rejected (strict)
|
||||
|
||||
- **WHEN** an unknown key is added to any options object
|
||||
- **THEN** the parse fails
|
||||
|
||||
#### Scenario: Non-function clock and empty initialBranchLabel are rejected
|
||||
|
||||
- **WHEN** `clock` is not a function OR `initialBranchLabel` is empty
|
||||
- **THEN** the parse fails
|
||||
|
||||
**Verified by:** `src/schemas/undo.schemas.test.ts::"accepts empty object"` AND `src/schemas/undo.schemas.test.ts::"accepts valid options"` AND `src/schemas/undo.schemas.test.ts::"rejects negative maxEntries"` AND `src/schemas/undo.schemas.test.ts::"rejects zero maxEntries"` AND `src/schemas/undo.schemas.test.ts::"rejects non-integer maxEntries"` AND `src/schemas/undo.schemas.test.ts::"rejects negative maxMemoryBytes"` AND `src/schemas/undo.schemas.test.ts::"rejects unknown keys (strict)"` AND `src/schemas/undo.schemas.test.ts::"rejects non-function clock"` AND `src/schemas/undo.schemas.test.ts::"rejects empty initialBranchLabel"` AND `src/schemas/undo.schemas.test.ts::"rejects unknown keys"` AND `src/schemas/undo.schemas.test.ts::"accepts zero compressionDepth (means compress everything)"` AND `src/schemas/undo.schemas.test.ts::"rejects negative compressionDepth"` AND `src/schemas/undo.schemas.test.ts::"rejects negative maxHistorySize"` AND `src/schemas/undo.schemas.test.ts::"rejects non-integer values"` AND `src/schemas/undo.schemas.test.ts::"rejects zero maxCount"` AND `src/schemas/undo.schemas.test.ts::"accepts fractional timeWindowMs"` AND `src/schemas/undo.schemas.test.ts::"rejects zero timeWindowMs"` AND `src/schemas/undo.schemas.test.ts::"accepts valid options including base fields"`
|
||||
|
||||
## Quality gates
|
||||
|
||||
- 100% Istanbul coverage on all source files (statements / branches / functions / lines).
|
||||
- Property-based tests via `fast-check` covering round-trips and invariants.
|
||||
- All class members `protected` (not `private`) for extensibility.
|
||||
- Docs coverage 100% (sibling `.md` for every implementation `.ts`).
|
||||
19
openspec/archive/2026-04-29-undo-spec-conformance/tasks.md
Normal file
19
openspec/archive/2026-04-29-undo-spec-conformance/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Tasks — undo-spec-conformance
|
||||
|
||||
> Heading rewrite + verification annotation only.
|
||||
|
||||
## 1. Spec rewrite
|
||||
|
||||
- [x] 1.1 Read `openspec/specs/undo/spec.md`
|
||||
- [x] 1.2 Inventory existing tests across 10 test files
|
||||
- [x] 1.3 Rewrite `openspec/specs/undo/spec.md` to nine `### Requirement: <name>` blocks with `**Verified by:**` lines
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 `bun scripts/openspec-conformance.ts @suaveplan/undo` — exit 0, no `error` findings
|
||||
- [x] 2.2 `bunx turbo lint typecheck test --filter=@suaveplan/undo` — green
|
||||
- [x] 2.3 `bunx turbo test:coverage --filter=@suaveplan/undo` — 100/100/100/100
|
||||
|
||||
## 3. Archive
|
||||
|
||||
- [x] 3.1 Move `openspec/changes/undo-spec-conformance/` → `openspec/archive/2026-04-26-undo-spec-conformance/`
|
||||
Reference in New Issue
Block a user