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 by 16ef377:

- 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:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+181
View File
@@ -458,6 +458,108 @@ struct GitUndoForwardTests {
}
}
// MARK: - Restore subjects compose the inverse
/// **"Subjects don't nest either crossing a restore composes the inverse"** (06-history-undo.md
/// Commit messages, settled 2026-07-31). The composer is a pure function of the crossed subject and
/// the direction (`GitHistoryProvider.restoreSubject(_:crossing:)`), so most of this suite needs no
/// repository at all and the one test that does is the case the rule exists for: the relaunch that
/// turns yesterday's restore commit into an ordinary step.
@MainActor
@Suite("Git undo ▸ restore subjects compose the inverse")
struct GitUndoRestoreSubjectTests {
@Test("An ordinary subject takes one prefix, per direction")
func anOrdinarySubjectNestsOnce() {
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Move card 'Fix login' to Doing")
== "Undo: Move card 'Fix login' to Doing")
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Move card 'Fix login' to Doing")
== "Redo: Move card 'Fix login' to Doing")
}
@Test("Undoing across a restore emits the inverse label, not a second prefix")
func undoingARestoreInverts() {
// 06's own two examples: "crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
// 'Undo: S'" because an undo restores the crossed commit's *parent*, the state that
// commit took away.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Move card 'X'")
== "Redo: Move card 'X'")
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Move card 'X'")
== "Undo: Move card 'X'")
}
@Test("Redoing across a restore restates it — the mirror of the undo rule, not a copy of it")
func redoingARestoreRestates() {
// A redo restores the target commit *itself*, so the label the new commit carries is that
// commit's own reading: Z back across an "Undo: S" step lands on the tree where S is out.
// Emitting "Redo: S" there the label the Z that crossed it already used, for the opposite
// tree would be the euphemism 06 rules out.
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Move card 'X'")
== "Undo: Move card 'X'")
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Redo: Move card 'X'")
== "Redo: Move card 'X'")
}
@Test("A legacy double prefix reads as two flips, and comes out carrying one")
func theLegacyDoublePrefixReadsAsTwoFlips() {
// **The honest reading of a commit the shipped nesting build made.** "Undo: Undo: S" undid
// the commit that undid S, so its tree is the one where S is *in*. Undoing across it puts S
// back out "Undo: S" which is what 06's "the truer label, not a euphemism" asks for;
// "Redo: S" would claim the opposite tree, and "Redo: Undo: S" would keep the nesting the
// ruling caps at one ("it caps prefixes at one across any number of relaunches").
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Undo: Move card 'X'")
== "Undo: Move card 'X'")
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Undo: Move card 'X'")
== "Redo: Move card 'X'")
// The legacy redo's shape reads the same way: "Redo:" restates whatever follows it.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Undo: Move card 'X'")
== "Redo: Move card 'X'")
// And any depth caps at one, which is the property the ruling actually claims the reading
// is the parity of the "Undo:"s (two here, so the tree has the move in it) and never the
// depth of the stack.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Redo: Undo: Move card 'X'")
== "Undo: Move card 'X'")
}
@Test("A prefix with nothing after it is somebody's subject, not a label")
func aBarePrefixIsASubject() {
// The sniff is on the subject string (06), and a subject that is *only* a prefix has no base
// to talk about stripping it would compose "Undo: " with nothing, naming no change at all.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: ") == "Undo: Undo: ")
// Foreign subjects that merely look like prefixes are unaffected the match is exact.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "undo: fix the build")
== "Undo: undo: fix the build")
}
@Test("After a relaunch, ⌘Z over yesterday's restore commits the inverse — the trail never nests")
func theRelaunchCaseLandsTheInverse() async throws {
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let provider = await makeProvider(fixture, git, committer: committer)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
committer.noteReloadLanded(sawForeignChange: true)
await commitAndSettle(committer, provider)
await provider.cross(.undo)
#expect(try subjects(at: fixture.root).first == "Undo: Rename card 'First' → 'Renamed'")
// **The relaunch**, which is what `reseed()` is: the stack starts again at HEAD with an empty
// redo, so the restore commit above is now an ordinary step the pointer sits on.
await provider.reseed()
#expect(provider.undoActionName == "Undo: Rename card 'First' → 'Renamed'",
"the menu label is still the crossed commit's own subject — labels never nested")
await provider.cross(.undo)
let subjects = try subjects(at: fixture.root)
#expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'",
"the trail says what the restore did: the rename is back")
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
}
}
// MARK: - Heal transparency
@MainActor
@@ -1121,6 +1223,85 @@ struct GitUndoBindingTests {
}
}
// MARK: - Failures reach the strip as failures
/// **The one-shot failure class's second shape, wired** (02-architecture.md The banner surface,
/// settled 2026-07-31): "a failed undo restore, branch switch, or (pro-m2) pull/push is an action
/// that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone
/// loss row (the shipped loss-row compromise is retired)".
///
/// These are wiring tests: what the session hands each seam, and which class of row comes out the
/// other side. The sentences themselves are `BannerCenterTests`' subject, and the precedence is
/// `BannerCenter.rows(...)`'.
@MainActor
@Suite("Git undo ▸ a failed git operation is a failure row")
struct GitOperationFailureBannerTests {
@Test("A failed restore posts the git failure shape, named by the key that was pressed")
func aFailedRestorePostsAFailureRow() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await seed.addGit())
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
let provider = try #require(session.history as? GitHistoryProvider)
// What `restore(_:to:message:)` hands the seam when libgit2 refuses: the direction, and the
// library's own message. The `operation` string on the failure is developer-facing and is
// deliberately not what the user reads.
provider.reportFailure?(.undo, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: "could not write to 'index.md': Permission denied"
))
let banners = session.store.banners
#expect(banners.gitFailures.count == 1)
#expect(banners.gitFailures.first?.operation == .undo)
#expect(banners.gitFailures.first?.reason == "could not write to 'index.md': Permission denied")
#expect(banners.losses.isEmpty, "the loss-row compromise is retired — this is a failure")
#expect(banners.oneShots.isEmpty, "and it stays off the closed WriteOperation vocabulary")
// Z's mirror, from the same seam and the same closure.
provider.reportFailure?(.redo, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: "the repository is locked"
))
#expect(banners.gitFailures.map(\.operation) == [.redo, .undo], "newest first, like every one-shot")
}
@Test("A failed branch switch posts the same shape; the interruption recovery stays a loss row")
func theSwitcherReportsFailureAndRecoveryDifferently() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await seed.addGit())
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
switcher.reportFailure?(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "your local changes would be overwritten"
))
// **The recovery is a success report** "a branch switch was interrupted the previous
// state is restored" so it keeps the warning tone the ruling leaves it (02).
switcher.reportRecovery?(GitOperationStamp.interruptionMessage)
let banners = session.store.banners
#expect(banners.gitFailures.map(\.operation) == [.branchSwitch])
#expect(banners.losses.map(\.message) == [GitOperationStamp.interruptionMessage])
}
}
// MARK: - Routing
@MainActor