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:
@@ -87,6 +87,13 @@ struct BannerCenterOrderingTests {
|
||||
)
|
||||
let operation = InProgressOperation(label: "Pulling…")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||
// The failure class's second shape (settled 2026-07-31) — newer than the failed move, so it
|
||||
// leads the rank the two of them share.
|
||||
let restore = GitFailureBanner(
|
||||
operation: .undo,
|
||||
reason: "could not write to 'index.md': Permission denied",
|
||||
occurredAt: Date(timeIntervalSince1970: 120)
|
||||
)
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: .vanishedRoot,
|
||||
@@ -95,27 +102,87 @@ struct BannerCenterOrderingTests {
|
||||
losses: [loss],
|
||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||
operations: [operation],
|
||||
signposts: [signpost]
|
||||
signposts: [signpost],
|
||||
gitFailures: [restore]
|
||||
)
|
||||
|
||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot write failures >
|
||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures, both shapes >
|
||||
// loss rows > commit and attachment failures > passive info rows. The two info classes
|
||||
// sit at opposite ends of the strip.
|
||||
#expect(rows.map(\.id) == [
|
||||
"operation:\(operation.id.uuidString)",
|
||||
"read-only-lock",
|
||||
"reload-breakage",
|
||||
"git-failure:\(restore.id.uuidString)",
|
||||
"one-shot:\(move.id.uuidString)",
|
||||
"loss:\(loss.id.uuidString)",
|
||||
"history-suspension",
|
||||
"one-shot:\(attachment.id.uuidString)",
|
||||
"signpost:\(signpost.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .warning, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false],
|
||||
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
||||
}
|
||||
|
||||
@Test("Both failure shapes share one rank, interleaved by recency")
|
||||
func theFailureRankHoldsBothShapes() {
|
||||
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
|
||||
// banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency
|
||||
// — not vocabulary — decides which of them a user reads first.
|
||||
let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||
let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3))
|
||||
let oldSwitch = GitFailureBanner(
|
||||
operation: .branchSwitch,
|
||||
reason: "your local changes would be overwritten",
|
||||
occurredAt: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
let newUndo = GitFailureBanner(
|
||||
operation: .undo,
|
||||
reason: "the repository is locked",
|
||||
occurredAt: Date(timeIntervalSince1970: 4)
|
||||
)
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [oldMove, newMove],
|
||||
losses: [LossBanner(message: "Folders can't be attached — 1 skipped")],
|
||||
suspension: nil,
|
||||
operations: [],
|
||||
gitFailures: [oldSwitch, newUndo]
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id).prefix(4) == [
|
||||
"git-failure:\(newUndo.id.uuidString)",
|
||||
"one-shot:\(newMove.id.uuidString)",
|
||||
"git-failure:\(oldSwitch.id.uuidString)",
|
||||
"one-shot:\(oldMove.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning],
|
||||
"and every one of them is a failure, above the warning-tone loss row")
|
||||
}
|
||||
|
||||
@Test("A git failure outranks a loss row however much older it is — the compromise is retired")
|
||||
func aGitFailureOutranksALossRow() {
|
||||
// The shipped build posted these as loss rows, which put a failed ⌘Z *below* a folder-drop
|
||||
// notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31).
|
||||
let ancient = GitFailureBanner(
|
||||
operation: .redo,
|
||||
reason: "the repository is locked",
|
||||
occurredAt: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [],
|
||||
gitFailures: [ancient]
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"])
|
||||
#expect(rows.map(\.tone) == [.error, .warning])
|
||||
}
|
||||
|
||||
@Test("An attachment failure ranks below other one-shots even when it is newer")
|
||||
func attachmentFailuresRankLast() {
|
||||
let attachment = OneShotBanner(
|
||||
@@ -294,12 +361,40 @@ struct BannerCenterLifecycleTests {
|
||||
#expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
|
||||
}
|
||||
|
||||
@Test("Dismissing all dismissable rows clears losses along with one-shots and signposts")
|
||||
@Test("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly")
|
||||
func gitFailuresDismissByIDAndNeverExpire() throws {
|
||||
let center = BannerCenter()
|
||||
center.postGitFailure(.undo, reason: "the repository is locked")
|
||||
center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten")
|
||||
#expect(center.gitFailures.count == 2)
|
||||
#expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion")
|
||||
|
||||
let doomed = try #require(center.gitFailures.first)
|
||||
center.dismiss(doomed.id)
|
||||
|
||||
#expect(center.gitFailures.count == 1)
|
||||
#expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour")
|
||||
|
||||
// No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it.
|
||||
let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil)
|
||||
center.endOperation(id)
|
||||
center.suspendHistory(reason: "disk full")
|
||||
center.clearHistorySuspension()
|
||||
#expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal")
|
||||
|
||||
// And it is a *failure*, so nothing about it lands in the loss class.
|
||||
#expect(center.losses.isEmpty)
|
||||
#expect(center.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts")
|
||||
func dismissAllClearsLosses() {
|
||||
let center = BannerCenter()
|
||||
center.postLoss("Pasted 'Fix login' without its 3 attachments")
|
||||
center.postGitFailure(.redo, reason: "the repository is locked")
|
||||
center.dismissAllDismissableRows()
|
||||
#expect(center.losses.isEmpty)
|
||||
#expect(center.gitFailures.isEmpty)
|
||||
}
|
||||
|
||||
@Test("postSkippedFolders no-ops when nothing was skipped")
|
||||
@@ -485,8 +580,11 @@ struct BannerRowControlsTests {
|
||||
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
|
||||
|
||||
let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked")
|
||||
|
||||
for (row, id) in [
|
||||
(BannerRow.oneShot(banner), banner.id),
|
||||
(BannerRow.gitFailure(gitFailure), gitFailure.id),
|
||||
(BannerRow.loss(loss), loss.id),
|
||||
(BannerRow.signpost(signpost), signpost.id),
|
||||
] {
|
||||
@@ -522,6 +620,7 @@ struct BannerRowControlsTests {
|
||||
.readOnlyLock(.vanishedRoot),
|
||||
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
|
||||
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
|
||||
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
|
||||
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
||||
.historySuspended(HistorySuspension(reason: "disk full")),
|
||||
.inProgress(InProgressOperation(label: "Pulling…")),
|
||||
@@ -612,6 +711,53 @@ struct BannerCenterPhrasingTests {
|
||||
#expect(uneditable.contains("frontmatter"))
|
||||
}
|
||||
|
||||
@Test("Every git operation names itself in the user's words, with the error as the tail")
|
||||
func everyGitOperationSaysSomethingDistinct() {
|
||||
// The vocabulary is closed and the sentences are here, not at the call sites (02 § The
|
||||
// banner surface, settled 2026-07-31: "the operation named in the user's words plus the
|
||||
// underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test
|
||||
// honest when pro-m2 adds pull and push.
|
||||
let headlines = GitOperation.allCases.map {
|
||||
BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked"))
|
||||
}
|
||||
|
||||
for (operation, headline) in zip(GitOperation.allCases, headlines) {
|
||||
#expect(!headline.isEmpty, "\(operation) has no headline")
|
||||
#expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error")
|
||||
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
|
||||
}
|
||||
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
|
||||
}
|
||||
|
||||
@Test("The git failure's sentences are the ruling's own")
|
||||
func gitFailureSentencesArePinned() {
|
||||
// Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand
|
||||
// and the third is their mirror: the undo pair names the command the user pressed, the
|
||||
// switch names the control they used.
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked"))
|
||||
== "Undo failed — the repository is locked")
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked"))
|
||||
== "Redo failed — the repository is locked")
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(
|
||||
operation: .branchSwitch,
|
||||
reason: "your local changes would be overwritten"
|
||||
)) == "Couldn't switch branches — your local changes would be overwritten")
|
||||
|
||||
// The tail is trimmed like every other diagnostic tail, and an absent one leaves the action
|
||||
// clause alone rather than trailing a dash into nothing.
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. "))
|
||||
== "Undo failed — the disk is full")
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed")
|
||||
}
|
||||
|
||||
@Test("The restore pair maps from the direction the provider crossed in")
|
||||
func restoreOperationsMapFromDirection() {
|
||||
// The provider knows which key was pressed and nothing else about banners; this is the whole
|
||||
// of the translation, kept in one place so no wiring can get it backwards.
|
||||
#expect(GitOperation.restore(.undo) == .undo)
|
||||
#expect(GitOperation.restore(.redo) == .redo)
|
||||
}
|
||||
|
||||
@Test("Every lock reason says what is wrong and that the view is still the last good one")
|
||||
func lockHeadlinesReassure() {
|
||||
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
|
||||
@@ -742,16 +888,20 @@ struct BannerCenterStoreTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.enterUnwritableLock(.permissionDenied)
|
||||
store.banners.postGitFailure(.undo, reason: "the working tree is locked")
|
||||
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
|
||||
store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
|
||||
store.banners.suspendHistory(reason: "the disk is full")
|
||||
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
||||
store.banners.postSignpost("This card changed on the remote")
|
||||
|
||||
// The git failure posts before the write failure, so recency (and the tie rule alike)
|
||||
// puts the write one-shot first within the shared failure rank.
|
||||
#expect(store.bannerRows.map(\.id) == [
|
||||
"operation:\(store.banners.operations[0].id.uuidString)",
|
||||
"read-only-lock",
|
||||
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
||||
"git-failure:\(store.banners.gitFailures[0].id.uuidString)",
|
||||
"loss:\(store.banners.losses[0].id.uuidString)",
|
||||
"history-suspension",
|
||||
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||
|
||||
Reference in New Issue
Block a user