Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -39,8 +39,16 @@ import Foundation
|
||||
/// A step that applies is pushed onto the opposite stack **reversed** — its two halves swapped
|
||||
/// (`HistoryStep.reversed`) — which gives the whole classic dance (undo → redo → undo …) with one
|
||||
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
|
||||
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
|
||||
/// a skipped step leaves nothing crossable behind: it is popped and never re-pushed, which is 13's
|
||||
/// "popped from the stack ... and ⌘Z falls through to the next step".
|
||||
///
|
||||
/// ### A third collection, which is not a stack
|
||||
///
|
||||
/// What a stale skip leaves behind is not *nothing*, though, and that is the one place this type
|
||||
/// keeps state a stack cannot express: a skipped step that was holding undo backing goes on holding
|
||||
/// it until the session ends (`strandedSteps`; 13 ▸ Interaction with the trash, ruled 2026-07-31 —
|
||||
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
|
||||
/// menu; it is a hold outliving the history it belonged to.
|
||||
@MainActor
|
||||
public final class NativeHistoryProvider: HistoryProviding {
|
||||
|
||||
@@ -48,6 +56,26 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
private var undoSteps: [HistoryStep] = []
|
||||
private var redoSteps: [HistoryStep] = []
|
||||
|
||||
/// **Steps a stale skip popped that were still holding something** — off both stacks, uncrossable
|
||||
/// forever, and *not yet retired* (13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31):
|
||||
///
|
||||
/// > "the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded,
|
||||
/// > or dropped off the end — or when the board session ends; **a stale-skipped step's backing
|
||||
/// > instead survives to board-session end** ... the skip banner says nothing was applied, and an
|
||||
/// > irreversible purge riding that gesture would be surprise loss — the skip is exactly when the
|
||||
/// > user may want to inspect what the collision left."
|
||||
///
|
||||
/// **The whole step is kept, not a copy of what it was holding**, and that is the ruling's "one
|
||||
/// condition, two consumers" taken literally: the sweep's ownership question (`backedContent`) and
|
||||
/// the deferred purge's moment (`HistoryStep.Retirement`) are two readings of one object's hold,
|
||||
/// so they must not become two facts able to disagree. A skip that ran the retirement and re-armed
|
||||
/// the sweep from a side-table would be saying *released* to one consumer and *still held* to the
|
||||
/// other, for the same instant. Deferring the latch itself says it once.
|
||||
///
|
||||
/// Only steps that owe something are stranded — see `strand(_:)`.
|
||||
private var strandedSteps: [HistoryStep] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
@@ -73,16 +101,44 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// membership question, and the two arrays already answer it.
|
||||
public var pendingSteps: [HistoryStep] { undoSteps }
|
||||
|
||||
/// **What every step this substrate still holds is holding** — the sweep's gate
|
||||
/// (`HistoryProviding.backedContent`; 13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31).
|
||||
///
|
||||
/// All *three* collections, unlike `pendingSteps` above, and the difference is the question: that
|
||||
/// one asks what ⌘Z would cross *now*, this one asks what has not been released. A step on the
|
||||
/// redo stack has not retired — "undone-and-superseded" is a release condition precisely because
|
||||
/// being undone is not one — so its backing is still owed; and a stranded step is the case where
|
||||
/// the two questions come furthest apart, holding backing it will never again cross for
|
||||
/// (`strandedSteps`, the skip-purge decoupling ruled 2026-07-31). The readings differing is what
|
||||
/// makes them two members.
|
||||
///
|
||||
/// Leaving the stranded out would defeat the ruling by the back door: the step is off the stack,
|
||||
/// so the next window open's sweep would call its backing residue and purge exactly the content
|
||||
/// the skip was meant to leave standing.
|
||||
public var backedContent: Set<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
for step in undoSteps { backing.formUnion(step.backing) }
|
||||
for step in redoSteps { backing.formUnion(step.backing) }
|
||||
for step in strandedSteps { backing.formUnion(step.backing) }
|
||||
return backing
|
||||
}
|
||||
|
||||
/// Records one undoable step and clears the redo stack — the classic rule, and the one every
|
||||
/// substrate shares.
|
||||
///
|
||||
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
|
||||
/// moment the app can see it.
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition — a *clean* exit in the
|
||||
/// ruling's own word — and it is the only moment the app can see it.
|
||||
///
|
||||
/// `strandedSteps` is deliberately untouched: superseding is something that happens to a step on
|
||||
/// the redo stack, and a stranded step is on no stack to be superseded from. Its one exit is the
|
||||
/// session's end.
|
||||
public func register(_ step: HistoryStep) {
|
||||
undoSteps.append(step)
|
||||
retire(redoSteps)
|
||||
let dropped = redoSteps
|
||||
redoSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
public func undo() { cross(.undo) }
|
||||
@@ -92,17 +148,38 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// Session teardown, the add-git substrate swap, a branch reseed — every step goes, so every step
|
||||
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
|
||||
/// ends" (13 ▸ Interaction with the trash).
|
||||
///
|
||||
/// **This is where the stranded finally pay**, and the only place they can: "a stale-skipped
|
||||
/// step's backing instead survives to board-session end" (ruled 2026-07-31) names exactly this
|
||||
/// moment, and `AppModel`'s teardown reaches it with the store still alive — the `clear()` is a
|
||||
/// line above the store's release, for the reason the deferred purge needs it to be.
|
||||
public func clear() {
|
||||
retire(undoSteps)
|
||||
retire(redoSteps)
|
||||
let dropped = undoSteps + redoSteps + strandedSteps
|
||||
undoSteps.removeAll()
|
||||
redoSteps.removeAll()
|
||||
strandedSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
/// **Off the stack first, then retired** — the order every caller above keeps, and the one
|
||||
/// `backedContent` depends on: a retirement is where the deferred purge runs, and a step that
|
||||
/// retired while still listed would be answering that it still holds the very content it is
|
||||
/// releasing.
|
||||
private func retire(_ steps: [HistoryStep]) {
|
||||
for step in steps { step.retirement?.run() }
|
||||
}
|
||||
|
||||
/// Keeps a skipped step's *hold* alive without keeping the step crossable — see `strandedSteps`.
|
||||
///
|
||||
/// **A step holding nothing is dropped outright**, which is every board gesture: with no backing
|
||||
/// to spare and no retirement to defer there is no consumer for the entry, and stranding it would
|
||||
/// be bookkeeping that only grows. The predicate is the pair the ruling's two consumers read, so
|
||||
/// it can never spare a step neither of them would ask about.
|
||||
private func strand(_ step: HistoryStep) {
|
||||
guard step.retirement != nil || !step.backing.isEmpty else { return }
|
||||
strandedSteps.append(step)
|
||||
}
|
||||
|
||||
// MARK: - The crossing
|
||||
|
||||
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 13's
|
||||
@@ -119,9 +196,13 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
push(step.reversed, onto: direction.opposite)
|
||||
return
|
||||
case .skipped:
|
||||
// Dropped for good — the third of the deferred purge's release conditions ("gone
|
||||
// stale"), and the reason a skip is reported here rather than merely counted.
|
||||
step.retirement?.run()
|
||||
// **Off the stack, but not released** — the skip-purge decoupling (13 ▸ Interaction
|
||||
// with the trash, ruled 2026-07-31). A stale skip is the one exit that is not clean:
|
||||
// "the skip banner says nothing was applied, and an irreversible purge riding that
|
||||
// gesture would be surprise loss — the skip is exactly when the user may want to
|
||||
// inspect what the collision left". So the step stops being crossable here and its
|
||||
// hold outlives it, to the session's end (`strandedSteps`).
|
||||
strand(step)
|
||||
continue
|
||||
case .failed:
|
||||
push(step, onto: direction)
|
||||
|
||||
Reference in New Issue
Block a user