diff --git a/Fixtures/Malformed/explicit-null-order.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/explicit-null-order.kanban/10000000-0000-4000-8000-000000000001/index.md deleted file mode 100644 index 841b9ff..0000000 --- a/Fixtures/Malformed/explicit-null-order.kanban/10000000-0000-4000-8000-000000000001/index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -schema: 1 -order: -title: Lane With Null Order ---- -The hand-editor started the 'order' key and never gave it a value — reads -as missing, not malformed. diff --git a/Fixtures/Malformed/explicit-null-order.kanban/index.md b/Fixtures/Malformed/explicit-null-order.kanban/index.md deleted file mode 100644 index ed97047..0000000 --- a/Fixtures/Malformed/explicit-null-order.kanban/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -schema: 1 -title: Board Is Fine ---- diff --git a/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md deleted file mode 100644 index 2b620e9..0000000 --- a/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -schema: 1 -title: Card Missing Order ---- -No 'order' key — required on every card. diff --git a/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/index.md deleted file mode 100644 index 68adfef..0000000 --- a/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -schema: 1 -order: 1024 -title: Lane Is Fine ---- diff --git a/Fixtures/Malformed/missing-order-card.kanban/index.md b/Fixtures/Malformed/missing-order-card.kanban/index.md deleted file mode 100644 index ed97047..0000000 --- a/Fixtures/Malformed/missing-order-card.kanban/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -schema: 1 -title: Board Is Fine ---- diff --git a/Fixtures/Malformed/missing-order-lane.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/missing-order-lane.kanban/10000000-0000-4000-8000-000000000001/index.md deleted file mode 100644 index 5823b1a..0000000 --- a/Fixtures/Malformed/missing-order-lane.kanban/10000000-0000-4000-8000-000000000001/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -schema: 1 -title: Lane Missing Order ---- -No 'order' key — required on every lane. diff --git a/Fixtures/Malformed/missing-order-lane.kanban/index.md b/Fixtures/Malformed/missing-order-lane.kanban/index.md deleted file mode 100644 index ed97047..0000000 --- a/Fixtures/Malformed/missing-order-lane.kanban/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -schema: 1 -title: Board Is Fine ---- diff --git a/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md deleted file mode 100644 index 12f5472..0000000 --- a/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -schema: 1 -order: banana -title: Card With Non-Numeric Order ---- -'order' is present but not a number — malformed, not missing; the same -loud rejection as a missing value. diff --git a/Fixtures/Malformed/non-numeric-order.kanban/index.md b/Fixtures/Malformed/non-numeric-order.kanban/index.md deleted file mode 100644 index ed97047..0000000 --- a/Fixtures/Malformed/non-numeric-order.kanban/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -schema: 1 -title: Board Is Fine ---- diff --git a/Fixtures/README.md b/Fixtures/README.md index 88fd68d..0bfd8a4 100644 --- a/Fixtures/README.md +++ b/Fixtures/README.md @@ -22,6 +22,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never | `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. | | `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. | | `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. | +| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. | ## Malformed/ — one board per fail-fast case @@ -30,10 +31,8 @@ Each board is minimal: one broken thing. | Board | Case | |---|---| | `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. | -| `missing-schema.kanban` | Board root `index.md` has no `schema` key. | +| `missing-schema.kanban` | Board root `index.md` has no `schema` key — the **root only**; below it a missing `schema` reads as 1 (`Valid/optional-keys.kanban`). | | `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. | -| `missing-order-lane.kanban` | A UUID-shaped lane `index.md` with no `order` key. | -| `missing-order-card.kanban` | A UUID-shaped card `index.md` with no `order` key. | -| `explicit-null-order.kanban` | A lane's `order:` key present with no value — reads as missing, not malformed. | -| `non-numeric-order.kanban` | A card's `order: banana` — present but not a number. | | `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. | + +The four `order` boards that used to live here — `missing-order-lane`, `missing-order-card`, `explicit-null-order`, `non-numeric-order` — were retired on 2026-07-31, when `order` became optional below the board root. Their shapes all live on in `Valid/optional-keys.kanban` as coercion cases. diff --git a/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..a85fab2 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +kind: card +order: 1024 +title: Ranked Card +--- +The only card in this lane that says where it goes. Every sibling below reads as +append-at-end, in folder-name order. diff --git a/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..fb56ee5 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,6 @@ +--- +title: Minimum Agent Card +--- +The whole legal minimum: one `mkdir` and one write, no `order` and no `schema` +(08-agent-integration.md — "filing a card must need nothing but the schema"). +Reads as schema 1, at the bottom of the lane. diff --git a/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..2d9cf80 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +kind: card +order: +title: Null Order Card +--- +The hand-editor started the key and never gave it a value — reads as missing, +which below the root is the append-at-end reading. diff --git a/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000004/index.md b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000004/index.md new file mode 100644 index 0000000..f4b67a9 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000004/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +kind: card +order: banana +title: Non-Numeric Order Card +--- +Present but unusable — the same reading a missing key gets, recorded as a +coercion with the text as written. diff --git a/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000005/index.md b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000005/index.md new file mode 100644 index 0000000..bee0354 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000005/index.md @@ -0,0 +1,9 @@ +--- +schema: 1 +kind: card +order: .nan +title: Non-Finite Order Card +--- +NaN has no place in the total order the tie-break and midpoint math assume, so +it is unusable exactly like `banana` — a coercion since 2026-07-31, not a +rejection. diff --git a/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/index.md similarity index 50% rename from Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/index.md rename to Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/index.md index 68adfef..0e502db 100644 --- a/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/index.md +++ b/Fixtures/Valid/optional-keys.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -1,5 +1,6 @@ --- schema: 1 +kind: lane order: 1024 -title: Lane Is Fine +title: Ranked Lane --- diff --git a/Fixtures/Valid/optional-keys.kanban/30000000-0000-4000-8000-000000000003/20000000-0000-4000-8000-000000000009/index.md b/Fixtures/Valid/optional-keys.kanban/30000000-0000-4000-8000-000000000003/20000000-0000-4000-8000-000000000009/index.md new file mode 100644 index 0000000..de45a17 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/30000000-0000-4000-8000-000000000003/20000000-0000-4000-8000-000000000009/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +kind: card +order: 1024 +title: Card In The Orderless Lane +--- diff --git a/Fixtures/Valid/optional-keys.kanban/30000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/optional-keys.kanban/30000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..547886a --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/30000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +kind: lane +title: Orderless Lane +--- +No rank at all: sorts right of every ranked lane in the strip. diff --git a/Fixtures/Valid/optional-keys.kanban/40000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/optional-keys.kanban/40000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..7bfba42 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/40000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,7 @@ +--- +kind: lane +order: 2048 +title: Schemaless Lane +--- +No `schema` below the root reads as 1 — the walk validating this file against +schema 1 is what makes the reading reliable. diff --git a/Fixtures/Valid/optional-keys.kanban/index.md b/Fixtures/Valid/optional-keys.kanban/index.md new file mode 100644 index 0000000..d826b16 --- /dev/null +++ b/Fixtures/Valid/optional-keys.kanban/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +kind: board +title: Optional Keys +--- +The board root keeps its `schema` — the this-really-is-a-board gate. Everything +below it may leave `order` and `schema` out entirely (01-storage-format.md +§ Frontmatter and § Ordering, re-ruled 2026-07-31). diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 5ea10cc..133065c 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -812,6 +812,15 @@ public final class AppModel { git.activateAutoCommit { [weak store] committer in guard let store else { return } committer.currentSnapshot = { [weak store] in store?.snapshot } + // **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled + // 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush + // awaits a snapshot generation covering its changed paths before the committer runs — + // the commit's subject can never be outrun by its own reload". Both halves of that + // await are reads of the store the composer is already diffing, which is why they are + // wired here rather than reached for: the engine holds the *policy* (when to wait, how + // long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`). + committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() } + committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration } // 02-architecture.md ▸ Write-failure surfacing, through the strip the board window // already renders: a genuine commit failure means "your edits are saved, history has // stopped advancing", which is exactly what the standing suspension row says. Lock @@ -839,6 +848,14 @@ public final class AppModel { git.didAddGit = { [weak self] in self?.bindHistoryProvider(for: ref) } + // **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers, + // ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead + // when that form has been dismissed before the answer arrived — "inline is the primary + // surface, never a silence trap". The banner enumeration is the same one branch switch and + // undo restore post into, one row per failure. + git.reportFailure = { [weak store] failure in + store?.banners.postGitFailure(.addGit, reason: failure.message) + } } // **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the // store is that boundary — every app-mediated mutation goes out through one of its write @@ -900,17 +917,16 @@ public final class AppModel { self?.resumeCardSessionStaging(for: ref) } // **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a - // one-shot banner failure naming the operation and the error, the tree left as it was"). + // one-shot banner failure naming the operation and the error, the tree left as it was") — + // now literally that, at the failure rank in the error tone (02 ▸ The banner surface, settled + // 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise + // this line used to carry is retired: a ⌘Z that didn't happen is an action that didn't + // happen, not content that didn't arrive. // - // Posted as a **loss row**, and the compromise is recorded rather than hidden: the true - // failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed - // `WriteOperation` vocabulary — and a git operation is deliberately not one of those - // (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest - // class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that - // can name both halves 06 asks for. A message-carrying failure class is the right fix and is - // a banner-surface change, not this card's. - provider.reportFailure = { [weak store] failure in - store?.banners.postLoss(failure.description) + // The closure passes the *direction* and libgit2's own message and stops there — "Undo + // failed — …" is BannerCenter's sentence, from the closed `GitOperation` vocabulary. + provider.reportFailure = { [weak store] direction, failure in + store?.banners.postGitFailure(.restore(direction), reason: failure.message) } provider.runBracketed = { [weak store] subject, work in guard let store else { return await work() } @@ -978,11 +994,16 @@ public final class AppModel { store?.banners.updateOperation(id, label: label) } switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) } - // The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise — see it for why - // a git failure cannot be a `OneShotBanner` today. + // The failure rank's git shape, as on the restore path above: a switch that didn't happen is + // an action that didn't happen ("Couldn't switch branches — …", BannerCenter's words from + // the operation alone). switcher.reportFailure = { [weak store] failure in - store?.banners.postLoss(failure.description) + store?.banners.postGitFailure(.branchSwitch, reason: failure.message) } + // **The recovery notice stays a loss row**, and the ruling is explicit about why (02 ▸ The + // banner surface): "recovery notices report a success, not a failure, and stay warning-tone". + // "A branch switch was interrupted — the previous state is restored" is the app tidying up + // after itself, with nothing for the user to do — the loss class's own register. switcher.reportRecovery = { [weak store] message in store?.banners.postLoss(message) } @@ -1009,7 +1030,16 @@ public final class AppModel { let outcome = await gate.settleAll() // The switch's flush runs next and must find a tree it can settle whole — see // `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough. - if outcome == .proceed { self.releaseCardSessionStaging(for: ref) } + // + // **And the fine undo stacks go with it** (06 ▸ Branch switching, ruled 2026-07-31): the + // same `.proceed`, the same seam, for the same reason one rung up — what a window is + // holding describes the branch being left. "Cancel keeps the current branch and the + // sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly + // as it releases nothing. + if outcome == .proceed { + self.releaseCardSessionStaging(for: ref) + self.discardCardWindowUndoStacks(for: ref) + } return outcome } @@ -1019,6 +1049,23 @@ public final class AppModel { Task { await switcher.recoverInterruptedOperation() } } + /// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production. + /// + /// `SessionSettleGate` already keeps the presentation behind a closure for its own reason + /// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the + /// presentation is a seam and the decision is testable"), and every gate this model builds pointed + /// that closure straight at the alert — so the *composition* around the gate, which is what + /// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only + /// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test + /// answer Save All, Discard and Cancel over real card windows without a modal on screen. + /// + /// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam + /// rather than API: production never assigns it. + @ObservationIgnored + var settleAsk: @MainActor (String) async -> SessionSettleChoice = { + await SessionSettleStep.ask(message: $0) + } + /// **The save-or-discard step for one board**, built from its open card windows /// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching). /// @@ -1055,7 +1102,10 @@ public final class AppModel { ) } }, - ask: { await SessionSettleStep.ask(message: message) }, + ask: { [weak self] in + guard let self else { return await SessionSettleStep.ask(message: message) } + return await self.settleAsk(message) + }, focus: { [weak self] id in guard let self, let session = self.sessions[ref] else { return } guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return } @@ -1212,6 +1262,43 @@ public final class AppModel { } } + /// **The branch switch's settle empties every open card window's fine undo stack** + /// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31). + /// + /// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe + /// > the branch being left — Save All and Discard alike end with every window's stack empty, the + /// > board-stack discard-and-reseed precedent one level down; the windows stay open, following + /// > their cards onto the new branch with fresh stacks." + /// + /// ### Why here, beside the staging release + /// + /// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of + /// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both + /// are true of a session whose branch is about to be replaced under it. Running them from one + /// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule + /// two call sites have to keep in step (`wireBranchSwitching`). + /// + /// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds + /// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket + /// (`GitBranchSwitcher.reseedUndo` → `GitHistoryProvider.reseed`). Doing it here would be the same + /// discard, one level up, at the wrong moment — before the checkout that decides what to reseed + /// *from*. + /// + /// **The restore path deliberately does not call this.** An undo restore materializes a diff and + /// leaves the branch where it is, so a window's steps still describe the branch they were made on; + /// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a + /// per-step question rather than a wholesale one. + /// + /// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close + /// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two + /// writes — and a fine undo stack is none of those things. The one type that has one is the card + /// window's own session, which is what every registration passes. + func discardCardWindowUndoStacks(for ref: BoardWindowRef) { + for cardRef in sessions[ref]?.cardRefs ?? [] { + (cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps() + } + } + /// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the /// operation behind the settle has finished with the tree. /// diff --git a/Kanban/App/TemplateEngine.swift b/Kanban/App/TemplateEngine.swift index 94c72e3..cf26b36 100644 --- a/Kanban/App/TemplateEngine.swift +++ b/Kanban/App/TemplateEngine.swift @@ -428,6 +428,15 @@ enum TemplateEngine { // The lane list is the loader's own level detection — `directoryCandidates` (hidden // entries and symlinks already out) narrowed by the identity predicate — so a stray // folder at board level is never descended into here either. + // **The seed, before the normalization it governs** (01-storage-format.md § Fractal + // layout ▸ Rules and 06-history-undo.md ▸ Repository hygiene, ruled 2026-07-31: every + // board carries a `.gitignore`, and an instantiated board is a board created today). A + // template that carries one of its own copies through and is honored — the seed writes + // only into a free name — which is 09-templates.md's "strays copy through" doing the + // right thing by itself; a template written before the ruling gains the seed here rather + // than a reload later, so the new board's very first walk has its noise gate. + try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root) + let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? []) .filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } for lane in lanes { diff --git a/Kanban/Git/CommitAttribution.swift b/Kanban/Git/CommitAttribution.swift index 874539a..f4e3e39 100644 --- a/Kanban/Git/CommitAttribution.swift +++ b/Kanban/Git/CommitAttribution.swift @@ -108,6 +108,19 @@ public enum CommitAttribution { /// and the generic external author". public static let agentEmailDomain = "agents.lanework.invalid" + /// **Who a heal commit is by** (06 ▸ Commit messages ▸ Healing mutations commit separately, ruled + /// 2026-07-31 — "the third pinned synthetic, joining Lanework External and the agent-slug family; + /// strings are API"): + /// + /// > a heal is a third origin — not the user's gesture, not a foreign writer — and the separation + /// > exists for audit, so the trail filters by author like every origin; the committer stays the + /// > user (the recorded-by convention above). + /// + /// It replaced authoring heals as the user, which made the separate commit filterable only by + /// message shape — and the shape vocabulary deliberately never says "healed". + public static let integrityAuthorName = "Lanework Integrity" + public static let integrityAuthorEmail = "integrity@lanework.invalid" + /// The frontmatter key a foreign writer refines its own attribution with /// (01-storage-format.md; 08-agent-integration.md teaches it). static let modifiedByKey = "modified-by" @@ -116,6 +129,13 @@ public enum CommitAttribution { GitIdentity(name: externalAuthorName, email: externalAuthorEmail) } + /// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's + /// identity, every time — "every commit the app makes, foreign-authored included, records the + /// user's app as its committer" (06). + public static var integrityIdentity: GitIdentity { + GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail) + } + /// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the /// synthetic email `@agents.lanework.invalid` (display name verbatim, email local part /// slugified)". diff --git a/Kanban/Git/CommitMessageComposer.swift b/Kanban/Git/CommitMessageComposer.swift index 1761aa1..65b9b95 100644 --- a/Kanban/Git/CommitMessageComposer.swift +++ b/Kanban/Git/CommitMessageComposer.swift @@ -75,6 +75,23 @@ public struct CommitMessageRequest: Sendable { /// be read, or has been deleted. public let agentGuideText: String? + /// **When each of this commit's comments was created** — keyed by the comment folder's + /// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it. + /// + /// The second value on this struct that a *file* has to be read for, and it is here for + /// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically — by the + /// comments' own `created`, folder name on ties" (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31), + /// and `created` lives in a comment's own `index.md` because comments are window-scoped and the + /// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves + /// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure + /// function of values. + /// + /// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit + /// (the close purge), one whose `index.md` does not parse, one written by hand with no `created` + /// at all — each is simply absent here and sorts after its dated siblings in folder-name order, + /// which is `CommentThread.sorted`'s own fallback for the same field. + public let commentTimestamps: [String: Date] + public init( boardRoot: URL, changedPaths: [GitChangedPath], @@ -82,7 +99,8 @@ public struct CommitMessageRequest: Sendable { isRootCommit: Bool, snapshot: BoardModel?, previousSnapshot: BoardModel? = nil, - agentGuideText: String? = nil + agentGuideText: String? = nil, + commentTimestamps: [String: Date] = [:] ) { self.boardRoot = boardRoot self.changedPaths = changedPaths @@ -91,6 +109,7 @@ public struct CommitMessageRequest: Sendable { self.snapshot = snapshot self.previousSnapshot = previousSnapshot self.agentGuideText = agentGuideText + self.commentTimestamps = commentTimestamps } } diff --git a/Kanban/Git/CommitMessageEngine.swift b/Kanban/Git/CommitMessageEngine.swift index 80e7e13..18635c3 100644 --- a/Kanban/Git/CommitMessageEngine.swift +++ b/Kanban/Git/CommitMessageEngine.swift @@ -83,6 +83,11 @@ enum CommitMessageEngine { /// container with no identity of its own (`BoardModel.trash`). static let trashDestination = "the trash" + /// What a key's old (or new) side reads as when the frontmatter simply did not carry it — the + /// `(untitled)` convention applied to a value rather than to a name, so "sprint: (none) → 42" + /// says *added* without a second sentence shape for it. + static let absentValuePlaceholder = "(none)" + // MARK: - Entry point /// One request in, one whole message out — a subject, and a body when there is more to say. @@ -242,8 +247,12 @@ enum CommitMessageEngine { previous: previous.document, current: current.document, noun: "board", + // The trio's board phrasing is count-less and title-less ("Relabel board") — there is one + // board and naming it adds nothing. The custom-key event is the one place 06 spells the + // board's title out ("Change custom key on board '⟨title⟩'"), so it gets it separately. itemTitle: nil, - kinds: (.relabelBoard, .assignBoard, .dueBoard, .updateBoard), + customKeyItemTitle: title(current.title), + kinds: (.relabelBoard, .assignBoard, .dueBoard, .changeKeyBoard), paths: paths ) return events @@ -380,7 +389,8 @@ enum CommitMessageEngine { current: new.document, noun: "lane", itemTitle: new.displayTitle, - kinds: (.relabelLane, .assignLane, .dueLane, .updateLane), + customKeyItemTitle: new.displayTitle, + kinds: (.relabelLane, .assignLane, .dueLane, .changeKeyLane), paths: paths ) } @@ -637,7 +647,8 @@ enum CommitMessageEngine { current: new.card.document, noun: "card", itemTitle: new.displayTitle, - kinds: (.relabelCard, .assignCard, .dueCard, .updateCard), + customKeyItemTitle: new.displayTitle, + kinds: (.relabelCard, .assignCard, .dueCard, .changeKeyCard), paths: paths ) return events @@ -684,9 +695,19 @@ enum CommitMessageEngine { /// **The full schema-1 surface, plus the reserved trio, deliberately** (06 ▸ The external gap, /// closed): "label, assignee, and due changes compose … even though 01-storage-format.md reserves - /// those keys out of this version's UI — external writers … are exactly who touches them. A change - /// to any other unmodeled or custom key composes a named generic ('Update card 'X'') — **never a - /// board-level shrug when the touched item is identifiable**." + /// those keys out of this version's UI — external writers … are exactly who touches them." + /// + /// **A change to any other unmodeled or custom key says what it is** (06, re-ruled 2026-07-31 — + /// "first lines self-describe; generics are a last resort, kept very rare"): + /// + /// > **"Change custom key on card 'X'"** (board and lane likewise — "Change custom key on board + /// > '⟨title⟩'"; several keys fold plural), the body naming each key with its old → new values. + /// + /// The named generic it retired ("Update card 'X'") is 06's own last resort and survives only + /// "for a change in a known file that is neither a vocabulary event nor a key change — a shape + /// that should almost never occur". Nothing composes that shape today: a known file whose diff is + /// neither is the *bookkeeping* rule's silence, which composes nothing at all. So the vocabulary + /// keeps no case for it, and the zero-event floor stays `unnamedSubject`'s. /// /// Read off `unknownFields`, which is exactly "every key the schema does not own" — so the /// bookkeeping keys are excluded by construction rather than by a list kept in step: `modified`, @@ -701,7 +722,8 @@ enum CommitMessageEngine { current: FrontmatterDocument, noun: String, itemTitle: String?, - kinds: (label: Kind, assignee: Kind, due: Kind, generic: Kind), + customKeyItemTitle: String, + kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind), paths: [String] ) -> [Event] { let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last }) @@ -726,11 +748,24 @@ enum CommitMessageEngine { if changed(Keys.labels) { events.append(event(kinds.label, "Relabel")) } if changed(Keys.assignees) { events.append(event(kinds.assignee, "Assign")) } if changed(Keys.due) { events.append(event(kinds.due, "Set due date on")) } - // Everything else the schema does not own — one named generic for the item, however many - // custom keys an agent touched in the same window. + + // **Everything else the schema does not own says what it is** (re-ruled 2026-07-31). The + // *item* is still what the subject names — one event however many keys moved — but the verb + // now describes the change instead of shrugging at it, and the keys themselves ride the body. let trio: Set = [Keys.labels, Keys.assignees, Keys.due] - if Set(before.keys).union(after.keys).subtracting(trio).contains(where: changed) { - events.append(event(kinds.generic, "Update")) + let keys = Set(before.keys).union(after.keys).subtracting(trio).filter(changed).sorted() + if !keys.isEmpty { + let subject = keys.count == 1 ? "custom key" : "\(keys.count) custom keys" + events.append(Event( + kind: kinds.customKey, + subject: "Change \(subject) on \(noun) \(quotedSubject(customKeyItemTitle))", + bullet: "Change \(subject) on \(noun) \(quoted(customKeyItemTitle))", + detail: keys + .map { "\($0): \(before[$0] ?? absentValuePlaceholder) → \(after[$0] ?? absentValuePlaceholder)" } + .joined(separator: "\n"), + destination: customKeyItemTitle, + paths: paths + )) } return events } @@ -750,10 +785,17 @@ enum CommitMessageEngine { /// lane is one event, not one line per file inside it); /// - the departing end of a **rename**, which its arrival already speaks for — the loose-file /// relocation, the remint, a displaced squatter. + /// + /// **One path shape is not a generic** (06 ▸ Commit messages ▸ Vocabulary, "Replace added + /// 2026-07-31"): "a changed file under a card's `attachments/` with an unchanged listing is a + /// content replacement, named from the path alone: 'Replace attachment 'photo.png' — card 'X'', + /// never the anonymous path generic". An unchanged listing is exactly "no model event claimed + /// this path", so the rule needs no second question of the snapshot — see `replacedAttachment`. private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] { let claimed = Set(model.flatMap(\.paths)) var comments: [String: CommentGroup] = [:] var events: [Event] = [] + lazy var cardTitles = cardTitlesByPath(request) for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) { let path = changed.path @@ -763,7 +805,11 @@ enum CommitMessageEngine { // schema): a trashed card's thread lives under `.trash/`, which the model-silence rule // would otherwise swallow whole. if let comment = CommentPath.classify(path) { - comments[CommentGroup.key(comment), default: CommentGroup(comment: comment)].add(changed) + let folder = commentFolder(of: path) ?? comment.cardPath + comments[ + CommentGroup.key(comment), + default: CommentGroup(comment: comment, folder: folder) + ].add(changed) continue } guard !Paths.isModelSilent(path) else { continue } @@ -778,6 +824,21 @@ enum CommitMessageEngine { events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path])) continue } + + // **Replace**, before the anonymous generic — the file is still listed, so the model had + // nothing to say and the path says it instead. + if let replaced = replacedAttachment(at: path) { + let card = cardTitles[replaced.cardFolder] ?? untitledPlaceholder + events.append(Event( + kind: .replaceFile, + subject: "Replace attachment \(quotedSubject(replaced.file)) — card \(quotedSubject(card))", + bullet: "Replace attachment \(quoted(replaced.file)) — card \(quoted(card))", + destination: card, + paths: [path] + )) + continue + } + events.append(Event( kind: .updatePath, subject: "Update \(quotedSubject(path))", @@ -788,12 +849,33 @@ enum CommitMessageEngine { return commentEvents(comments, model: model, request: request) + events } + /// **A card attachment rewritten in place** — the `Replace` shape, read from the path alone. + /// + /// `//attachments/`, and nothing else: an attachment one folder deeper is not a + /// shape this app writes, and a trashed card's is unreachable here (everything under `.trash/` is + /// model-silent and never gets this far). Whether the *listing* changed is the caller's question, + /// already answered — an added or removed file was claimed by its own Attach/Remove event before + /// this path was ever reached. + private static func replacedAttachment(at path: String) -> (cardFolder: String, file: String)? { + let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard components.count == 4, + components[2] == Paths.attachmentsFolder, + let folder = cardFolder(of: path) + else { return nil } + return (folder, components[3]) + } + // MARK: - The comment verb family /// Every changed path inside **one comment folder**, gathered so a comment that had its /// `index.md` and two attachments rewritten is one event rather than three. private struct CommentGroup { let comment: CommentPath + + /// This comment's own folder, board-root-relative — the key its `created` is looked up under + /// (`CommitMessageRequest.commentTimestamps`) and the name the chronology's tie-break reads. + let folder: String + var paths: [String] = [] var hasArrival = false var hasSurvivor = false @@ -806,6 +888,11 @@ enum CommitMessageEngine { } } + /// The folder's last component — the UUID a comment is named by, or `.draft`. + var folderName: String { + folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder + } + mutating func add(_ changed: GitChangedPath) { paths.append(changed.path) if changed.isArrival { hasArrival = true } @@ -813,6 +900,29 @@ enum CommitMessageEngine { } } + /// **The comment folder a changed path sits in**, board-root-relative — `/comments/`, + /// `/comments/.trash/` or `/comments/.draft` — or `nil` for a path that is not + /// inside a thread at all. + /// + /// Shared with the flush that reads each of those folders' `created` + /// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed* + /// under and the key it is *looked up* by have one definition. Everything about where a thread + /// lives is still `CommentPath.classify`'s; this only says how many of the path's components that + /// classification consumed. + static func commentFolder(of path: String) -> String? { + guard let comment = CommentPath.classify(path) else { return nil } + let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + // `classify` has already guaranteed these counts — `//comments/` for a + // comment or the draft, one deeper for `comments/.trash/`. + let depth: Int + switch comment.kind { + case .comment, .draft: depth = 4 + case .trashed: depth = 5 + } + guard components.count >= depth else { return nil } + return components.prefix(depth).joined(separator: "/") + } + /// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block: /// "foreign comment changes are described by **path shape** — the 'Update agent guide (vN)' /// mechanism: a changed path under `…/comments//` composes 'Comment on ⟨card title⟩' / 'Edit @@ -850,7 +960,7 @@ enum CommitMessageEngine { }) var events: [Event] = [] - for key in groups.keys.sorted() { + for key in chronological(groups, timestamps: request.commentTimestamps) { guard let group = groups[key], !group.paths.isEmpty else { continue } guard !relocated.contains(group.comment.cardPath) else { continue } let card = titles[group.comment.cardPath] ?? untitledPlaceholder @@ -891,6 +1001,47 @@ enum CommitMessageEngine { return events } + /// **A commit's comment bullets sort chronologically** (06-history-undo.md ▸ Rules ▸ Auto-commit, + /// blessed 2026-07-31): + /// + /// > "by the comments' own `created`, folder name on ties — event order reads as the conversation + /// > did, never UUID-arbitrary." + /// + /// The ladder is `CommentThread.sorted`'s, one layer up and over folders rather than over parsed + /// comments: `created` ascending, **the undated after the dated**, folder name on ties — compared + /// as the canonical lowercase spelling, the corpus-wide rule for every folder-name tie-break + /// (01-storage-format.md § Ordering). The thread the bullets describe is sorted by that ladder on + /// screen; a commit describing the same thread in a different order would be the same events told + /// out of sequence. + /// + /// **Chronology is global rather than per card**, recorded as a judgment call. A window's comment + /// events almost always belong to one card — the card-window session flush is that window by + /// construction — and where they do not, "the order the events happened in" is still the honest + /// reading of a list of events; grouping by card first would sort a conversation by which lane its + /// card sits in. The group key breaks the last tie so the order is total, which keeps a message + /// reproducible rather than hash-ordered. + private static func chronological( + _ groups: [String: CommentGroup], + timestamps: [String: Date] + ) -> [String] { + groups.keys.sorted { lhs, rhs in + guard let left = groups[lhs], let right = groups[rhs] else { return lhs < rhs } + switch (timestamps[left.folder], timestamps[right.folder]) { + case let (leftDate?, rightDate?) where leftDate != rightDate: + return leftDate < rightDate + case (.some, .none): + return true + case (.none, .some): + return false + default: + break + } + let leftName = IntegrityRules.canonicalIdentity(left.folderName) + let rightName = IntegrityRules.canonicalIdentity(right.folderName) + return leftName == rightName ? lhs < rhs : leftName < rightName + } + } + /// The card folders this commit says moved, arrived or went — the set a comment path checks /// itself against before speaking. private static func relocatedCardFolders(in model: [Event]) -> Set { @@ -962,17 +1113,17 @@ enum CommitMessageEngine { /// The verb-plus-noun grouping that decides what folds with what — **06's vocabulary, one case /// each**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes and - /// the board, Attach / Remove for attachment files, Repair for the remint, the trash pair, the - /// reserved metadata trio, and the two path shapes. + /// the board, Attach / Remove / **Replace** for attachment files, Repair for the remint, the trash + /// pair, the reserved metadata trio, the custom-key change, and the two path shapes. enum Kind: Hashable { case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard - case relabelCard, assignCard, dueCard, updateCard - case attachFile, removeFile, reorderCards, repairDuplicate + case relabelCard, assignCard, dueCard, changeKeyCard + case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane - case relabelLane, assignLane, dueLane, updateLane, reorderLanes + case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes - case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, updateBoard + case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard case agentGuide, updatePath @@ -1014,13 +1165,18 @@ enum CommitMessageEngine { case .relabelCard: return "Relabel \(count) cards" case .assignCard: return "Assign \(count) cards" case .dueCard: return "Set due date on \(count) cards" - case .updateCard: return "Update \(count) cards" + case .changeKeyCard: + guard let destination else { return "Change custom keys on \(count) cards" } + return "Change custom keys on card \(CommitMessageEngine.quotedSubject(destination))" case .attachFile: guard let destination else { return "Attach \(count) files" } return "Attach \(count) files to card \(CommitMessageEngine.quotedSubject(destination))" case .removeFile: guard let destination else { return "Remove \(count) files" } return "Remove \(count) files from card \(CommitMessageEngine.quotedSubject(destination))" + case .replaceFile: + guard let destination else { return "Replace \(count) attachments" } + return "Replace \(count) attachments — card \(CommitMessageEngine.quotedSubject(destination))" case .reorderCards: guard let destination else { return "Reorder cards in \(count) lanes" } return "Reorder cards in \(CommitMessageEngine.truncated(destination))" @@ -1037,7 +1193,9 @@ enum CommitMessageEngine { case .relabelLane: return "Relabel \(count) lanes" case .assignLane: return "Assign \(count) lanes" case .dueLane: return "Set due date on \(count) lanes" - case .updateLane: return "Update \(count) lanes" + case .changeKeyLane: + guard let destination else { return "Change custom keys on \(count) lanes" } + return "Change custom keys on lane \(CommitMessageEngine.quotedSubject(destination))" // A board has one title, one description, one style — and a lane reorder is a single // whole-board event. None of these can actually recur; the switch stays exhaustive. @@ -1048,7 +1206,11 @@ enum CommitMessageEngine { case .relabelBoard: return "Relabel board" case .assignBoard: return "Assign board" case .dueBoard: return "Set due date on board" - case .updateBoard: return CommitMessageEngine.unnamedSubject + // One board, so a plural of *events* here is a plural of keys already folded into one + // event — unreachable in practice, and count-less if it ever is. + case .changeKeyBoard: + guard let destination else { return "Change custom keys on board" } + return "Change custom keys on board \(CommitMessageEngine.quotedSubject(destination))" case .agentGuide: return "Update agent guide" case .updatePath: return "Update \(count) files" @@ -1108,7 +1270,9 @@ enum CommitMessageEngine { /// Every `index.md` in the board's fractal layout, and everything inside `.trash/`. An /// attachment is deliberately **not** here: the model carries attachment *names*, so an added /// or removed file composes its own event, while a rewritten one — same name, new bytes — has - /// nothing in the snapshot to show for it and rightly composes "Update '⟨path⟩'". + /// nothing in the snapshot to show for it and composes **Replace** off its path instead + /// (`replacedAttachment(at:)`, 06 ▸ Commit messages ▸ Vocabulary, added 2026-07-31 — "never + /// the anonymous path generic"). /// Whether a path could make the *snapshot* differ at all — every path the model speaks for, /// plus attachments, whose names it carries. /// diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index 8ab7ade..5f73188 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -134,6 +134,45 @@ public final class GitAutoCommitter { @ObservationIgnored public var currentSnapshot: (@MainActor () -> BoardModel?)? + /// **The reload pipeline settling** — `BoardStore.awaitQuiescence()`, and `nil` on a storeless + /// committer. + /// + /// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes + /// the *next* walk a walk that started after this flush's changes were on disk. + @ObservationIgnored + public var awaitReloadQuiescence: (@MainActor () async -> Void)? + + /// **Which generation the board `currentSnapshot` answers with is at** — + /// `BoardStore.snapshotGeneration`, incremented by every landed reload. + /// + /// `nil` — the closure absent, or answering `nil` because the store has gone — means there is no + /// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless + /// committer. + @ObservationIgnored + public var snapshotGeneration: (@MainActor () -> Int?)? + + /// **How long an explicit flush waits for its covering reload** before composing from the snapshot + /// it already has. + /// + /// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the + /// flush awaits its covering snapshot and does not say what happens if that reload never lands. It + /// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by + /// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket + /// reload … even if not one filesystem event was seen"), so the wait is short and certain in every + /// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths, + /// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries + /// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is + /// composed from the snapshot in hand — one stale subject in a degraded configuration, against a + /// hang. + @ObservationIgnored + public var coveringSnapshotDeadline: Duration = .seconds(1) + + /// How often the wait re-reads the generation. Polled rather than signalled for + /// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and + /// a continuation resumed by a reload that never lands has no way to. + @ObservationIgnored + public var coveringSnapshotPollInterval: Duration = .milliseconds(10) + /// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but /// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried /// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`. @@ -208,6 +247,23 @@ public final class GitAutoCommitter { @ObservationIgnored private var holdsForeignChanges = false + /// **Whether an app write has closed with no reload landed since** — the covering await's entry + /// gate (`awaitCoveringSnapshot()`). + /// + /// Set at every write-bracket close and cleared by every landing, so it answers exactly "is + /// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board + /// would wait out the whole deadline for a reload nothing has any reason to schedule. + /// + /// **The one corner it does not cover, recorded rather than discovered**: a reload that was + /// already *in flight* when the write bracket closed walked the pre-write tree, and its landing + /// clears this flag all the same — the store's landing signal carries no such distinction + /// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk + /// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write + /// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and + /// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running). + @ObservationIgnored + private var holdsUncoveredWrites = false + /// Open **card-window sessions**, each answering with the folder to stage around *right now*. /// /// A closure per session rather than a stored URL, because a card can move lane, or into the @@ -222,6 +278,10 @@ public final class GitAutoCommitter { @ObservationIgnored private var isFlushing = false + /// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`. + @ObservationIgnored + private var flushWaiters: [CheckedContinuation] = [] + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") init(boardRoot: URL, ledger: EchoLedger) { @@ -259,6 +319,9 @@ public final class GitAutoCommitter { /// this is the only moment at which the committer can still see them (`HarvestedReceipt`). public func noteWriteBracketClosed() { harvest() + // The snapshot the composer diffs is now known to be behind the tree until a reload lands — + // see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`. + holdsUncoveredWrites = true arm() } @@ -269,6 +332,7 @@ public final class GitAutoCommitter { /// *file* at flush time, because this is one bit about a whole reload. public func noteReloadLanded(sawForeignChange: Bool) { if sawForeignChange { holdsForeignChanges = true } + holdsUncoveredWrites = false arm() } @@ -307,7 +371,7 @@ public final class GitAutoCommitter { public func noteWillWrite() { guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return } isFlushing = true - defer { isFlushing = false } + defer { endFlushing() } pending?.cancel() pending = nil // One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means @@ -398,9 +462,110 @@ public final class GitAutoCommitter { /// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the /// sessions have ended, so nothing is staged around any more. public func flushNow() async { + await awaitCoveringSnapshot() + // **Queued behind an in-flight flush, never skipped** — see `awaitFlushInFlight()`. + await awaitFlushInFlight() await flush() } + /// **Suspends until no flush is running** — what makes `flushNow()` a promise rather than an + /// attempt (06-history-undo.md ▸ Rules ▸ Auto-commit: "nothing settled is ever left unsaved or + /// uncommitted by closing"). + /// + /// ### The bug this exists for + /// + /// `flush()` skips when one is already running, which is exactly right for the **debounce** — a + /// timer firing into a commit already in progress has nothing to add, and coalescing is the + /// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close + /// flush, the quit flush, the branch switch's pre-checkout flush and File ▸ Duplicate's: those + /// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is + /// empty, and a `return` gave them that answer while it was still full. + /// + /// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The + /// close sequence releases each session's stage-around and then nudges the committer + /// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its + /// card-drain deadline before reaching `committerFlush`. With the two intervals both at two + /// seconds the debounce fired *into* the drain's last moments about half the time — and the flush + /// it started had, in the worst case, planned its commit while the session's folder was still + /// staged around. So the in-flight flush committed nothing of the session, the close flush skipped + /// behind it, and teardown stopped the committer: the window's whole session was left uncommitted, + /// permanently, with no later flush anywhere that could have picked it up. Even in the benign + /// interleaving `closeBoard` returned — and at quit, `applicationShouldTerminate` replied — while + /// the commit was still detached work in flight. + /// + /// ### The shape + /// + /// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its + /// reason: this type is `@MainActor`, so there is no data race to exclude — only a *suspension* to + /// wait out — and the thing a caller wants is "tell me when it is over", which is what a resumed + /// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed + /// another on its way out cannot slip between the resume and the caller's own attempt. + private func awaitFlushInFlight() async { + while isFlushing { + await withCheckedContinuation { flushWaiters.append($0) } + } + } + + /// Ends one flush and releases whoever was queued behind it. The single exit for both flushing + /// paths — the debounced one and the synchronous flush-before-overwrite — so a waiter can never be + /// left suspended by a path that forgot it. + private func endFlushing() { + isFlushing = false + let waiters = flushWaiters + flushWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + /// **The flush awaits the snapshot that covers it** (06-history-undo.md ▸ Rules ▸ Auto-commit, + /// ruled 2026-07-31). + /// + /// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot + /// > generation covering its changed paths before the committer runs — the commit's subject can + /// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the + /// > practical cushion, never the guarantee." + /// + /// ### What "covering its changed paths" means to this store + /// + /// A reload is a **whole tree walk** — the store has no changed-path channel at all + /// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) — so a walk that *started* + /// after this flush's writes were on disk covers every path they touched, by construction. There + /// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the + /// generation counter a sufficient answer rather than an approximation of one. + /// + /// Two steps, in this order, are what turn it into a guarantee: + /// + /// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing + /// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is + /// owed, which is the moment after which every walk is a walk that started later. + /// 2. **Wait for one generation.** The write bracket that produced these changes already + /// scheduled the reload that will supply it — unconditionally, whether or not FSEvents said + /// anything (`FolderWatcher.endBracket`) — so this is a bounded wait on work already in the + /// pipeline, not a hope. + /// + /// ### Why only the explicit flush + /// + /// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush, + /// File ▸ Duplicate's pending-work step, and the undo restore's. Those are the flushes that run + /// *because* something just finished, which is exactly when the snapshot can still be one walk + /// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds + /// after the later of them — 06's own "practical cushion", doing the job it is enough for — and + /// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite. + private func awaitCoveringSnapshot() async { + guard holdsUncoveredWrites, let read = snapshotGeneration else { return } + await awaitReloadQuiescence?() + // Re-read the gate: the quiescence may itself have been the covering landing. + guard holdsUncoveredWrites, let base = read() else { return } + + let started = ContinuousClock.now + while let current = read(), current == base { + guard ContinuousClock.now - started < coveringSnapshotDeadline else { + Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand") + return + } + try? await Task.sleep(for: coveringSnapshotPollInterval) + } + } + /// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and /// typing churn" is one timer rather than a rule each call site remembers. private func arm(after interval: Duration? = nil) { @@ -414,10 +579,13 @@ public final class GitAutoCommitter { } } + /// One flush. **Skipping when one is already running is the debounce's rule and only the + /// debounce's** — an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`) + /// before it gets here, so this guard can only ever coalesce a timer. private func flush() async { guard !isFlushing else { return } isFlushing = true - defer { isFlushing = false } + defer { endFlushing() } pending?.cancel() pending = nil @@ -512,6 +680,7 @@ public final class GitAutoCommitter { var previous: BoardModel? var current: BoardModel? var agentGuideText: String? + var commentTimestamps: [String: Date] = [:] } /// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept @@ -537,6 +706,12 @@ public final class GitAutoCommitter { // card's title. So a comment-only window loads the current board and skips the materialization. let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) } let namesACard = changed.contains { CommentPath.classify($0.path) != nil } + // **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the + // one field of a comment the composer needs and the board snapshot cannot carry. Read beside + // the guide's bytes, for the guide's reason, and only for a window that names a comment at all. + if namesACard { + composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot) + } guard touchesModel || namesACard else { return composition } // **The store's snapshot when there is one, disk when there is not.** A storeless committer is @@ -550,6 +725,42 @@ public final class GitAutoCommitter { return composition } + /// **When each comment this window touched was created**, keyed by its folder — the chronology + /// `CommitMessageEngine` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed + /// 2026-07-31: "by the comments' own `created`, folder name on ties"). + /// + /// One `index.md` per touched comment folder, read off the **working tree** — which is the state + /// this commit is about to stage, and the only place a comment's own fields exist at all. A folder + /// this window *removed* (the close purge) has nothing left to read, and a comment whose + /// frontmatter does not parse or carries no `created` answers nothing either: all three are + /// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own + /// fallback for the same field. Nothing here is a defect and nothing is reported — a commit + /// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept). + /// + /// Internal rather than private so the composer's own suite can resolve the chronology exactly the + /// way a flush does, instead of hand-assembling a map the flush could never produce + /// (`WriterFixture.snapshot()`'s reason, restated one field down). + nonisolated static func commentTimestamps( + for changed: [GitChangedPath], + boardRoot: URL + ) -> [String: Date] { + var timestamps: [String: Date] = [:] + var seen: Set = [] + for path in changed { + guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted + else { continue } + let index = boardRoot + .appendingPathComponent(folder) + .appendingPathComponent(IntegrityRules.indexFileName) + guard let data = try? Data(contentsOf: index), + let document = try? BoardLoader.parseDocument(data, path: folder), + let created = document.created.value + else { continue } + timestamps[folder] = created + } + return timestamps + } + /// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes. private nonisolated static func plan( _ changed: [GitChangedPath], @@ -571,7 +782,8 @@ public final class GitAutoCommitter { isRootCommit: isRootCommit, snapshot: composition.current, previousSnapshot: composition.previous, - agentGuideText: composition.agentGuideText + agentGuideText: composition.agentGuideText, + commentTimestamps: composition.commentTimestamps ) } @@ -600,16 +812,23 @@ public final class GitAutoCommitter { authorship = .foreign( CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot) ) - // **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a - // heal's paths commit *separately* and says nothing about who they are by. The healer is - // the app acting on the user's behalf — its writes are app-mediated, receipt and all — so - // authoring them as the user is the honest reading, and authoring them as `Lanework - // External` would blame the outside world for the app's own repair. + // **A heal is authored `Lanework Integrity `** (06 ▸ Commit + // messages ▸ Healing mutations commit separately, ruled 2026-07-31): "a heal is a third + // origin — not the user's gesture, not a foreign writer — and the separation exists for + // audit, so the trail filters by author like every origin". This authored heals as the + // *user* until that ruling, which left the separate commit filterable only by message + // shape — and the shape vocabulary deliberately never says "healed". case .heal: authorship = .heal case .user: authorship = .user } + // The committer stays the user throughout — 06's recorded-by convention, which is why + // only the author varies here. let author: GitIdentity - if case let .foreign(identity) = authorship { author = identity } else { author = user } + switch authorship { + case let .foreign(identity): author = identity + case .heal: author = CommitAttribution.integrityIdentity + case .user: author = user + } let kind: PlannedCommitKind switch group.kind { case .foreign: kind = .foreign diff --git a/Kanban/Git/GitBranchSwitcher.swift b/Kanban/Git/GitBranchSwitcher.swift index eebf433..d65c718 100644 --- a/Kanban/Git/GitBranchSwitcher.swift +++ b/Kanban/Git/GitBranchSwitcher.swift @@ -90,15 +90,25 @@ public final class GitBranchSwitcher { /// A clean failure — "surfaces as a one-shot banner failure naming the operation and the error, /// the tree left as it was" (06 ▸ Interaction with external writers). /// - /// The banner rather than the popover's inline caption, deliberately, and 06 draws the line: the - /// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote), + /// The banner rather than an inline caption, deliberately, and 06 draws the line: the + /// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote — + /// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split), /// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any /// one surface" — which a branch switch is by construction, since its bracket locks the board and /// its completion is announced. + /// + /// **Which row that is, settled 2026-07-31** (02-architecture.md ▸ The banner surface): the + /// one-shot failure class's message-carrying git shape — error tone, failure rank, dismissable + /// and untimed. What travels is the operation and the underlying message; the sentence + /// ("Couldn't switch branches — …") is `BannerCenter`'s, which is why nothing here composes one. @ObservationIgnored public var reportFailure: (@MainActor (GitOperationFailure) -> Void)? /// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`). + /// + /// **A warning-tone loss row, not a failure** (02 ▸ The banner surface, settled 2026-07-31): + /// "recovery notices report a success, not a failure, and stay warning-tone" — the abort put the + /// previous state back, and the row exists so the user learns that it happened. @ObservationIgnored public var reportRecovery: (@MainActor (String) -> Void)? diff --git a/Kanban/Git/GitHistoryProvider.swift b/Kanban/Git/GitHistoryProvider.swift index 8585879..d285c29 100644 --- a/Kanban/Git/GitHistoryProvider.swift +++ b/Kanban/Git/GitHistoryProvider.swift @@ -93,8 +93,14 @@ public final class GitHistoryProvider: HistoryProviding { public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)? /// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it. + /// + /// **The direction travels with the failure** (02-architecture.md ▸ The banner surface, settled + /// 2026-07-31): the one-shot failure class's second shape names the operation in the user's + /// words — "Undo failed", "Redo failed" — and this object is the only one that knows which key + /// was pressed. Everything past that boundary is the banner's: the closure receives the + /// direction and libgit2's own message, never a sentence composed here. @ObservationIgnored - public var reportFailure: (@MainActor (GitOperationFailure) -> Void)? + public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)? // MARK: - The cached stack @@ -326,7 +332,11 @@ public final class GitHistoryProvider: HistoryProviding { guard let index = crossableIndex() else { return } let crossed = ancestry[index] guard let target = crossed.parentOID else { return } - let landed = await restore(to: target, message: "Undo: \(crossed.subject)") + let landed = await restore( + .undo, + to: target, + message: Self.restoreSubject(.undo, crossing: crossed.subject) + ) guard landed else { return } redoCommits.append(crossed) pointerOID = target @@ -334,7 +344,11 @@ public final class GitHistoryProvider: HistoryProviding { case .redo: guard let target = redoCommits.last else { return } - let landed = await restore(to: target.oid, message: "Redo: \(target.subject)") + let landed = await restore( + .redo, + to: target.oid, + message: Self.restoreSubject(.redo, crossing: target.subject) + ) guard landed else { return } redoCommits.removeLast() // The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance, @@ -349,7 +363,11 @@ public final class GitHistoryProvider: HistoryProviding { /// `message` is both the commit's subject and the bracket's completion announcement /// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at /// completion") — one sentence, so the trail and the speech cannot disagree about what happened. - private func restore(to target: String, message: String) async -> Bool { + /// + /// `direction` is carried for one reason: a failure here is the banner's git-operation shape, + /// and it is named by the key the user pressed rather than by the subject the restore would have + /// carried (`reportFailure`). + private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool { let root = boardRoot let excluded = healPaths @@ -358,7 +376,7 @@ public final class GitHistoryProvider: HistoryProviding { guard let preliminary = await Task.detached(priority: .userInitiated, operation: { GitRestoreOperation.plan(at: root, target: target, excluding: excluded) }).value else { - report("this board's repository could not be read") + report(direction, "this board's repository could not be read") return false } @@ -429,7 +447,7 @@ public final class GitHistoryProvider: HistoryProviding { case let .held(pause): Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)") case let .failed(failure): - self.reportFailure?(failure) + self.reportFailure?(direction, failure) } } @@ -478,10 +496,111 @@ public final class GitHistoryProvider: HistoryProviding { return nil } - private func report(_ message: String) { - reportFailure?(GitOperationFailure( + private func report(_ direction: HistoryDirection, _ message: String) { + reportFailure?(direction, GitOperationFailure( operation: GitRestoreOperation.operationName, message: message )) } + + // MARK: - The restore subject + + /// **What a restore commit is called** — a pure function of the crossed subject and the + /// direction, so the rule can be read (and pinned) without a repository. + /// + /// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: ⟨subject⟩" + /// or "Redo: ⟨subject⟩". **Subjects don't nest** (06 ▸ Commit messages, settled 2026-07-31): + /// when the crossed subject already carries a restore prefix — the post-relaunch case, where the + /// reseed has made old restore commits ordinary steps — the composer "emits the *inverse* label + /// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields + /// 'Undo: S'", which "caps prefixes at one across any number of relaunches". + /// + /// ### Why the two directions read the crossed subject differently + /// + /// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the + /// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed + /// commit's **parent** — the state before it — so it emits that commit's inverse; a redo + /// restores the target commit **itself**, so it emits that commit's own reading. That is what + /// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and + /// its mirror true with it: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out, + /// and says "Undo: S" — the truer label, rather than the "Redo: S" the ⌘Z that crossed it + /// already used for the opposite tree. + /// + /// ### The legacy double prefix + /// + /// "Undo: Undo: S" exists in the wild — the shipped nesting build made them — and the honest + /// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took + /// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits + /// **"Undo: S"** — the tree it restores is the one without S, and saying "Redo: S" there would be + /// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while + /// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips + /// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one. + /// + /// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to + /// open with a prefix gets the inverse label too; that's cosmetic — the restore itself is + /// unaffected". + public nonisolated static func restoreSubject( + _ direction: HistoryDirection, + crossing subject: String + ) -> String { + let reading = RestoreSubjectReading(of: subject) + let emitted = switch direction { + case .undo: reading.polarity.inverse + case .redo: reading.polarity + } + return "\(emitted.label): \(reading.base)" + } +} + +// MARK: - Helpers + +/// What a subject says about its own base subject: is that change *in* the tree the subject +/// describes, or has it been taken back out? Every restore label is one of these two readings, which +/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`). +private enum RestorePolarity { + /// The base subject's change is in the tree — every ordinary commit, and every "Redo: S". + case applied + /// The base subject's change has been taken back out — "Undo: S". + case reverted + + var inverse: RestorePolarity { self == .applied ? .reverted : .applied } + + /// The word that states this reading in a subject. + var label: String { self == .applied ? "Redo" : "Undo" } + + /// The same word as a prefix — the only two this composer emits, and the only two it reads, so + /// that reading and writing can never drift apart. + var prefix: String { "\(label): " } +} + +/// One subject read as "a base subject, plus what its restore prefixes say about it". +/// +/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a +/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's +/// subject, not a label with nothing after it. +private struct RestoreSubjectReading { + let base: String + let polarity: RestorePolarity + + init(of subject: String) { + var base = subject + var polarity = RestorePolarity.applied + while true { + let read: RestorePolarity + if base.hasPrefix(RestorePolarity.reverted.prefix) { + read = .reverted + } else if base.hasPrefix(RestorePolarity.applied.prefix) { + read = .applied + } else { + break + } + let rest = String(base.dropFirst(read.prefix.count)) + guard !rest.isEmpty else { break } + base = rest + // "Undo: " flips what the rest of the subject was saying; "Redo: " restates it. + if read == .reverted { polarity = polarity.inverse } + } + self.base = base + self.polarity = polarity + } } diff --git a/Kanban/Git/GitIdentity.swift b/Kanban/Git/GitIdentity.swift index 5fa6518..ca23a25 100644 --- a/Kanban/Git/GitIdentity.swift +++ b/Kanban/Git/GitIdentity.swift @@ -9,8 +9,10 @@ import Foundation /// /// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox /// because it lives under the board root, and the natural state of adopted/cloned boards." The -/// popover's name/email fields (a later card) write exactly that file: "the setting *is* the -/// file, portable to any git client, per-board by nature". +/// identity fields write exactly that file: "the setting *is* the file, portable to any git +/// client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31 +/// popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that +/// sheet is built, which changes nothing about this file. /// 2. **Absent repo config, the derived default**: "the macOS account's full name plus /// `shortname@hostname` — git's own no-config fallback shape, zero ceremony." /// @@ -142,8 +144,19 @@ enum GitConfigFile { } /// The parse, over text — the pure half, and where the format's edges are decided. + /// + /// **Reads take the last plain-section value** (06-history-undo.md ▸ Interaction with external + /// writers, blessed 2026-07-31): "the reader — like git itself — takes the last plain-section + /// value, which is exactly what an append produces." + /// + /// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different + /// key in git's own model — `user.work.name`, not `user.name` — so its values are not answers to + /// this question at all, and reading one would sign the user's commits with an identity they + /// filed under a name this app never asked about. Last-wins still holds inside the plain + /// sections: a later `[user]` overrides an earlier one, which is how an appended section wins + /// without the writer ever touching what came before it. static func identity(inConfigText text: String) -> (name: String?, email: String?) { - var section: String? + var isPlainUserSection = false var name: String? var email: String? @@ -152,17 +165,16 @@ enum GitConfigFile { if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue } if line.hasPrefix("[") { - // `[user]`, and `[user "work"]` — a subsection is somebody else's scope, so the - // header's first token is what names the section. let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" }) - section = header + let section = header .split(separator: " ", maxSplits: 1) .first .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + isPlainUserSection = section == "user" && !header.contains("\"") continue } - guard section == "user", let separator = line.firstIndex(of: "=") else { continue } + guard isPlainUserSection, let separator = line.firstIndex(of: "=") else { continue } let key = line[line.startIndex.. Result { let operation = "Adding git to this board" - guard !BoardGitMode.hasGitEntry(at: boardRoot) else { + switch BoardGitMode.detect(boardRoot: boardRoot) { + case .none: + break + case .git: return .failure(GitOperationFailure( operation: operation, message: "this board already has a git repository" )) + case .repoNested: + return .failure(GitOperationFailure( + operation: operation, + message: "this board lives inside a repository; Lanework leaves it to that repository" + )) } let gitDirectory: URL @@ -189,33 +196,32 @@ enum GitRepository { } } - /// **The `.gitignore` seed, written at init and never again** (06-history-undo.md ▸ Repository - /// hygiene: "the app never edits an existing one and never manages the file afterward — it's the - /// user's from then on"). + /// **The last-chance `.gitignore` seed, immediately before the initial commit.** /// - /// Three properties, and they are the feature: + /// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md ▸ Repository hygiene, + /// re-ruled: "`.gitignore` seeded on every board, never touched after … git or not"). Every board + /// the app creates is born with one, and every board it opens is healed into having one + /// (`BoardStore.seedGitignore`) — and add-git can only run on a board that is *open* and writable, + /// so by the time this line is reached the file is essentially always already there and this call + /// writes nothing. /// - /// - **Only when absent.** A board that already carries a `.gitignore` — from a template, from a - /// clone, from the user — is left byte for byte alone. `fileExists` rather than a read, so a - /// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks - /// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely - /// because nothing in the app reads this file). - /// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing - /// re-checks it, no heal restores it, no later version of the app appends to it: a user who - /// deletes the seeded line has deleted it. - /// - **Only on the app's own init.** Adoption seeds nothing — an adopted repository is somebody - /// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested - /// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode - /// but `none`, so this function is unreachable from there. + /// **It stays anyway, and stays here — before the stage below.** The one case it still answers is + /// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient + /// failure that armed its memo, a picture that has not changed since), the initial commit would + /// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where + /// this app has no operation that could ever remove it (06 ▸ Deleting never forgets). One + /// `lstat` on the one path that mints a repository is a cheap insurance policy against a + /// permanent record. + /// + /// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s — one seed text, one write-only-when-free + /// rule, `lstat` semantics — so this cannot drift from what board creation and the heal write. /// /// A write that fails is not a failure of add-git. The repository exists, the commit that follows - /// simply will not carry a `.gitignore`, and a board with none is an ordinary board — surfacing a - /// banner about a courtesy file would be louder than the thing it reports. + /// simply will not carry a `.gitignore`, and the board's own heal will try again at the next + /// open — surfacing a banner about a courtesy file would be louder than the thing it reports. private static func seedGitignoreIfAbsent(at boardRoot: URL) { - let url = boardRoot.appendingPathComponent(".gitignore") - guard !FileManager.default.fileExists(atPath: url.path) else { return } do { - try Data(seededGitignore.utf8).write(to: url, options: .atomic) + try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot) } catch { logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)") } diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index 5bb08ca..9ac5a8f 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -58,14 +58,26 @@ public final class HistoryStore { /// click from running `git_repository_init` twice. public private(set) var isAddingGit = false - /// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one). + /// The last add-git failure while the form that asked is still on screen, or `nil`. /// - /// Surfaced inline in the popover rather than as a banner: the popover is where the operation - /// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner - /// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle - /// add-git's failure surface either way. + /// **Form-anchored operations answer at the form first** (06 ▸ Interaction with external writers, + /// ruled 2026-07-31): "add-git — and later sheet-asked operations like verify-remote — fail into + /// an inline caption in the sheet's relevant section while the sheet is up … if the sheet has been + /// dismissed before the answer arrives, the failure falls back to the one-shot banner above — + /// inline is the primary surface, never a silence trap." + /// + /// So this property is exactly the *inline* half: it is set only while `isFormVisible`, and + /// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other + /// half is `reportFailure`, which posts the banner when the answer arrives to an empty room. + /// + /// The form is the popover's git section today and the board settings sheet once that exists — + /// the ruling's container moved in the 2026-07-31 popover/sheet split, its substance did not, and + /// `noteFormVisible(_:)` is the one line the sheet will re-point. public private(set) var lastFailure: GitOperationFailure? + /// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`). + public private(set) var isFormVisible = false + /// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit), or `nil` on a board /// there is no repository to commit into. /// @@ -152,6 +164,22 @@ public final class HistoryStore { @ObservationIgnored public var didAddGit: (@MainActor () -> Void)? + /// **The banner half of the form-anchored posture** — where a form-asked failure goes when the + /// form is gone (`BannerCenter.postGitFailure`). `nil` on a storeless `HistoryStore`, which has no + /// strip to post to; the inline half still works there. + @ObservationIgnored + public var reportFailure: (@MainActor (GitOperationFailure) -> Void)? + + /// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the + /// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there"). + /// + /// A `Bool` rather than a count because there is one such form per board at a time: the popover is + /// built fresh on each open and the settings sheet is modal to its board window. + public func noteFormVisible(_ visible: Bool) { + isFormVisible = visible + if !visible { lastFailure = nil } + } + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) { @@ -283,7 +311,15 @@ public final class HistoryStore { Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)") return true case .failure(let failure): - lastFailure = failure + // **Inline while the form is up, the banner when it is not** (06, ruled 2026-07-31) — the + // answer can outlive the surface that asked for it, and a failure with nowhere to land + // would be the silence trap the ruling names. + if isFormVisible { + lastFailure = failure + } else { + lastFailure = nil + reportFailure?(failure) + } Self.logger.error("add-git failed: \(failure.description, privacy: .public)") return false } diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift index 989ea4e..c96e270 100644 --- a/Kanban/History/BoardStoreHistory.swift +++ b/Kanban/History/BoardStoreHistory.swift @@ -107,6 +107,11 @@ extension BoardStore { let named = subject ?? name let step = HistoryStep( name: name, + // **What this step holds in a trash, read off what it already declares** — the sweep's + // gate (`HistoryStep.backing`, ruled 2026-07-31). Derived here rather than passed by each + // call site because the expectations *are* the claim: a step whose undo needs a folder + // present under `comments/.trash/` is a step whose undo is the move back out of it. + backing: HistoryStep.backing(declaredBy: undoExpects), retirement: retirement, undo: { [weak self] direction in BoardStore.cross(self, direction, named, undoExpects, undo) @@ -130,9 +135,9 @@ extension BoardStore { // MARK: The window close's coarse step /// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window - /// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card - /// ⟨title⟩', values-based, whose undo restores the card subtree to its session-start state — - /// deleted comments included — and whose redo reapplies the net effect"). + /// close ("the session's net effect registers on the board stack as one coarse step named + /// 'Changes to ⟨card⟩', values-based, whose undo restores the card subtree to its session-start + /// state — deleted comments included — and whose redo reapplies the net effect"). /// /// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about /// whether there is a board to register it on is this method's: @@ -152,10 +157,11 @@ extension BoardStore { /// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`. /// /// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash). - /// - Returns: whether the purge now has an owner — a live step holding it until the step leaves - /// the board stack, or a substrate that declined to keep the step and therefore ran it already - /// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still - /// owes the purge. + /// - Returns: whether the purge now has an owner — a live step holding it until the substrate + /// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling, + /// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it + /// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller + /// still owes the purge. @discardableResult func registerCardSession( _ window: CardWindowUndo, @@ -164,9 +170,18 @@ extension BoardStore { ) -> Bool { guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false } guard let net = window.netEffect() else { return false } + // **The title the step is registered with, not the one the session opened on** (the phrase is + // 13's "Changes to '⟨card⟩'", ruled 2026-07-31). Read once and spent twice, which is the + // reason: the menu row and the skip banner must quote *one* name for one step, and a step + // whose row said "Changes to 'Fix login'" while its banner said "'Fix log-in' changed outside + // Lanework" would be two names for one card. Registration time is also the honest instant — + // the row appears in the Edit menu the moment this returns, beside a board already showing + // this title, and a session that renamed the card is a session whose rename is *in* the net + // effect the row is offering to walk back. + let title = Self.cardTitle(at: target, in: snapshot) registerStep( - HistoryPhrase.cardSession, - subject: Self.cardTitle(at: target, in: snapshot), + HistoryPhrase.cardSession(title), + subject: title, retiring: retiring, undoExpects: net.undoExpects, redoExpects: net.redoExpects, diff --git a/Kanban/History/BoardUndoManager.swift b/Kanban/History/BoardUndoManager.swift index cbdccb8..699f74d 100644 --- a/Kanban/History/BoardUndoManager.swift +++ b/Kanban/History/BoardUndoManager.swift @@ -167,8 +167,11 @@ public final class BoardUndoManager: UndoManager { /// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the /// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would /// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way -/// (settled): the search field ... and the popover's text fields ... own ⌘Z/⇧⌘Z as field-local text -/// undo while focused — a reflexive undo over a typo must never become a tree checkout." So the +/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields +/// (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — a +/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the +/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them +/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the /// window-level answer is the board's stack **only when no text-editing surface holds the /// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would /// have created for such a window on its own, so nothing about typing in a field changes. diff --git a/Kanban/History/CardWindowUndo.swift b/Kanban/History/CardWindowUndo.swift index 0f7f61d..3d72ea2 100644 --- a/Kanban/History/CardWindowUndo.swift +++ b/Kanban/History/CardWindowUndo.swift @@ -101,6 +101,39 @@ public final class CardWindowUndo { writes[id] = write } + /// **Empties this window's fine stack, registering nothing** — the branch switch's settle step + /// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31). + /// + /// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe + /// > the branch being left — Save All and Discard alike end with every window's stack empty, the + /// > board-stack discard-and-reseed precedent one level down; the windows stay open, following + /// > their cards onto the new branch with fresh stacks." + /// + /// ### Closing the stack is not closing the window + /// + /// This is the whole reason it is a method rather than a call to `stack.clear()` at the call site. + /// A window *close* owes the board one coarse step folded from this stack + /// (`BoardStore.registerCardSession`, `netEffect()`); a settle clear owes it nothing, because the + /// window is not closing — it stays open on the other side of the checkout and its next gesture + /// starts a fresh stack. So nothing here registers, nothing folds, and the fold that runs at the + /// window's eventual close sees only what the *new* branch's session did: `netEffect()` reads + /// `stack.pendingSteps`, which this empties, and `registerCardSession` answers `false` over an + /// empty fold exactly as it does for a session with no net change. + /// + /// The raw writes go with the steps. They are keyed by step id and nothing on either stack names + /// them any more, so keeping them would be a table that only grows — and a redone step's half of + /// the fold, which is the one reason the table outlives the undo stack, has nothing left to be + /// half of once both stacks are empty. + /// + /// **Nothing retires here that would not have retired anyway.** `NativeHistoryProvider.clear()` + /// runs each dropped step's retirement, and a window's fine steps carry none: the deferred + /// `comments/.trash/` purge is the *coarse* step's (13-native-undo.md ▸ Interaction with the + /// trash), registered on the board's stack at close and never on this one. + func discardSteps() { + stack.clear() + writes.removeAll() + } + // MARK: - The fold /// **The session's net effect, or `nil` when there is none** — what the window's close registers @@ -111,7 +144,11 @@ public final class CardWindowUndo { /// /// - **undo** — every live step's undo, newest first. Replaying the session backwards lands on the /// state it started from, deleted comments included: their backing is still in - /// `comments/.trash/` because this step's own existence is what defers the purge. + /// `comments/.trash/` because this step's own existence is what defers the purge — and, since + /// the sweep gate (13 ▸ Interaction with the trash, ruled 2026-07-31), what keeps the *next* + /// window open from sweeping it as residue. That claim needs nothing of its own here: the fold + /// below carries each delete's `.present(.trashedComment(…))` into the coarse step's + /// expectations, and `HistoryStep.backing(declaredBy:)` reads it straight off them. /// - **redo** — every live step's redo, oldest first. The session, replayed. /// - **the undo's expectations** — the state the session's writes left, folded **last-write-wins** /// per field: what must still be true for the whole step to be safe to cross. diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift index 74fa3e2..537523c 100644 --- a/Kanban/History/HistoryPhrase.swift +++ b/Kanban/History/HistoryPhrase.swift @@ -25,6 +25,10 @@ import Foundation /// destination clause a commit subject carries has no place in a title that has to stay short enough /// for a menu row. /// +/// **One deliberate exception, and it is the only one**: `cardSession(_:)` names its card +/// ("Changes to 'Fix login'"), because the coarse close step is the one phrase whose *scope* is what +/// distinguishes it — see that member. +/// /// Pure, and its own type rather than a `String` built at each call site, because a phrase composed /// in eleven places is a vocabulary that drifts in eleven places. public enum HistoryPhrase { @@ -105,15 +109,37 @@ public enum HistoryPhrase { // MARK: The card-window session /// **The coarse close step's phrase** — one card window's whole session, as the board's stack sees - /// it: 13-native-undo.md ▸ Rules names it "Edit card 'Fix login'", so the menu title is the same - /// verb and noun every other card edit uses, dropping the item clause a menu row has no space for. + /// it: **"Changes to '⟨card⟩'"** (13-native-undo.md ▸ Rules, ruled 2026-07-31). /// - /// Deliberately *not* a new verb. The session is an edit to a card — the fine-grained things - /// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's - /// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem - /// in a menu (06-history-undo.md ▸ Commit messages). It reads identically to a body-edit step - /// because on the board's stack it *is* the card's edit. - public static let cardSession = name(.edit, kind: .card) + /// > "the session's net effect registers on the board stack as **one coarse step named + /// > "Changes to '⟨card⟩'"** … the board row reads "Undo Changes to 'Fix login'": plural and + /// > scope-flavoured, distinct from every fine verb, honest about folding many kinds; the fine + /// > body-edit wording never leaks onto the board menu." + /// + /// ### Why this one phrase carries its item + /// + /// Every other phrase here drops the item clause a commit subject carries, because a verb plus a + /// noun already says what the row is (the type's note). This step has no such verb: what it folds + /// is a comment posted, a colour chosen, a paragraph rewritten, all at once, and a row that named + /// any one of them would be lying about the other two — while enumerating them would be the + /// "Mixed update" problem in a menu (06-history-undo.md ▸ Commit messages). So the *scope* is the + /// phrase, and a scope is only legible when it names what it is the scope **of**. + /// + /// ### It is not `Edit Card`, and that is the point + /// + /// This member read `name(.edit, kind: .card)` until the ruling, and the fine body-edit step still + /// does (`registerBodyEdit`) — two different steps on two different stacks, one row apart in the + /// Edit menu, saying the same six characters. The board menu now says "Changes to 'Fix login'" and + /// the window menu says "Edit Card": the coarse row names the session, the fine row names the + /// gesture, and neither can be mistaken for the other. + /// + /// - Parameter title: the card's title **as the step is registered** — `nil` renders the same + /// placeholder the card's own window title bar renders ("Untitled" is a rendering, never a + /// value — 03-board-ui.md § Card face), so a menu row and the window it came from name the card + /// the same way. + public static func cardSession(_ title: String?) -> String { + "Changes to '\(title ?? "Untitled")'" + } // MARK: Composition diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index c9feba2..f4ac7d5 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -111,10 +111,18 @@ public struct HistoryStep { /// It exists for one consumer, and the design names it precisely: the window-close coarse step /// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores /// deleted comments, so their backing lives as long as the step does — the purge runs when the - /// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone - /// stale) or the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled - /// 2026-07-31). A step is the only object that knows all three of those moments, and it knows - /// none of them itself — so the *provider* reports them, through this. + /// coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end + /// — or when the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled + /// 2026-07-31). A step is the only object that knows those moments, and it knows none of them + /// itself — so the *provider* reports them, through this. + /// + /// **A stale skip is deliberately not one of them** (the skip-purge decoupling, ruled + /// 2026-07-31): "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". Leaving the stack and leaving history for good stopped being the same event + /// there, and this latch marks the second — which is why a substrate that keeps a skipped step's + /// hold alive holds the *step* (`NativeHistoryProvider.strandedSteps`) rather than running this + /// early and re-arming the sweep from somewhere else. /// /// **A reference type inside a value type, deliberately.** `reversed` copies the step every time /// it crosses, and the two copies must not each run the work: sharing one latch is what makes @@ -150,6 +158,26 @@ public struct HistoryStep { /// The menu phrase, unprefixed — see the type's note. public let name: String + /// **What this step holds as undo backing** — deleted content that exists on disk only because + /// *this step's undo would move it back out*, and which is therefore not residue for as long as + /// the step is crossable (13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31). + /// + /// > "`comments/.trash/` content referenced by a live coarse step on the board stack is a step's + /// > **backing, not residue** — the open-time sweep consults the stack and skips owned content, + /// > re-arming when the owning step leaves the stack (which is exactly when the deferred purge + /// > wanted to run; one condition, two consumers). Reopening a window can therefore never destroy + /// > its prior session's undo backing." + /// + /// The pair with `retirement` is the whole mechanism: this says *what* is being held, that says + /// *when the hold ends*, and the two are read by the two consumers the ruling names — the + /// crash-residue sweep (`BoardStore.sweepCommentTrashResidue(inCard:)`, through + /// `HistoryProviding.backedContent`) and the deferred purge itself. + /// + /// **Declared by the step's own expectations rather than by hand** — see `backing(declaredBy:)`. + /// Empty for every board gesture, which is all but a comment delete and the coarse step folding + /// one. + public let backing: Set + /// What this step owes when it leaves history — see `Retirement`. `nil` for every step that owes /// nothing, which is all but the card-window close step. public let retirement: Retirement? @@ -165,26 +193,58 @@ public struct HistoryStep { public init( id: UUID = UUID(), name: String, + backing: Set = [], retirement: Retirement? = nil, undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome, redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome ) { self.id = id self.name = name + self.backing = backing self.retirement = retirement self.undo = undo self.redo = redo } + /// **The backing a step's own registration already names**: every target its **undo** expects to + /// find *in a trash*. + /// + /// Derived rather than passed, so no call site can forget it and no call site can say something + /// its expectations contradict. The reading is exact: a step whose undo requires a folder to be + /// present under `comments/.trash/` is a step whose undo *is* the move back out of it — which is + /// the definition of backing, spelled in the currency the step already carries. + /// + /// **`.trashedComment` is the whole vocabulary**, deliberately. The board's own `.trash/` holds + /// the same relationship (a delete step's undo restores from it) and is deliberately absent: the + /// board trash is a UI surface the user empties on purpose, with a confirm, and nothing sweeps it + /// behind their back — so there is no consumer for the answer. `comments/.trash/` is the one + /// trash the app purges on its own schedule (01-storage-format.md § Enhanced schema), which is + /// exactly why it is the one that needs asking. + public static func backing(declaredBy undoExpects: [HistoryExpectation]) -> Set { + var backing: Set = [] + for expectation in undoExpects where expectation.presence == .present { + guard case .trashedComment = expectation.anchor else { continue } + backing.insert(expectation.anchor) + } + return backing + } + /// The same step read backwards — what a provider puts on the opposite stack once this one has /// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move /// Card": the phrase names the *gesture*, not the direction. /// - /// **The identity and the retirement travel with it**, both for the same reason: a step that has - /// crossed is the same step, so the fold that keyed state on it must still find that state, and - /// the purge it defers must still be owed exactly once. + /// **The identity, the backing and the retirement travel with it**, all three for one reason: a + /// step that has crossed is the same step, so the fold that keyed state on it must still find that + /// state, the content it holds must still be held, and the purge it defers must still be owed + /// exactly once. + /// + /// The backing claim is constant across the crossing rather than swapped with the closures, and + /// that is the honest reading: an undone coarse step has *already* moved its comments back out of + /// `comments/.trash/`, so there is nothing there to sweep — and its redo will put them back, + /// after which its undo needs them again. A claim that lapsed while the step sat on the redo stack + /// would be a claim that lapsed exactly when the step was still crossable. public var reversed: HistoryStep { - HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo) + HistoryStep(id: id, name: name, backing: backing, retirement: retirement, undo: redo, redo: undo) } } @@ -220,14 +280,31 @@ public struct HistoryStep { /// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's /// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`. /// -/// ### One obligation every implementation shares: retire what you drop +/// ### One obligation every implementation shares: retire what you let go of /// -/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` — the -/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so -/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it -/// clears on a `register`, a step it drops as stale, everything in `clear()`, and — for a substrate -/// that keeps no steps at all — the step handed to `register` itself. Nothing else in the app can -/// observe that moment, and a step dropped in silence would defer its work forever. +/// A step may owe work for as long as the substrate holds it and no longer (`HistoryStep.Retirement` +/// — the deferred `comments/.trash/` purge). Only the substrate knows when it has stopped holding +/// one, so **every implementation must call `retirement?.run()` on every step it lets go**: the redo +/// stack it clears on a `register`, everything in `clear()`, and — for a substrate that keeps no +/// steps at all — the step handed to `register` itself. Nothing else in the app can observe that +/// moment, and a step dropped in silence would defer its work forever. +/// +/// **Held is not the same as crossable**, since the skip-purge decoupling (13 ▸ Interaction with the +/// trash, ruled 2026-07-31): a step a stale skip popped is crossable by nothing, and its hold +/// nonetheless stands to the session's end, because "the skip banner says nothing was applied, and an +/// irreversible purge riding that gesture would be surprise loss". A substrate that pops stale steps +/// therefore has a place to put them (`NativeHistoryProvider.strandedSteps`); one that never keeps a +/// step is untouched by the distinction, which is why the git provider needed no change. +/// +/// ### And its mirror: say what you are still holding +/// +/// The same fact, asked the other way round — `backedContent`. Retirement is the *moment* a hold +/// ends; that is the *inventory* of holds standing right now, which is what a sweep needs before it +/// removes anything (13 ▸ Interaction with the trash, ruled 2026-07-31: "one condition, two +/// consumers"). The two must be answered off the same steps or the pair stops being one condition — +/// which is the whole reason a stranded step is kept whole rather than filleted into a retirement +/// here and a set of anchors there. It defaults to nothing, so a substrate that keeps no steps needs +/// no line of code. @MainActor public protocol HistoryProviding: AnyObject { @@ -262,4 +339,32 @@ public protocol HistoryProviding: AnyObject { /// Drops every step in both directions — session-only persistence (13 ▸ Rules), run at the board /// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first. func clear() + + /// **The undo backing every step this substrate still holds** — the union of `HistoryStep.backing` + /// over everything it has not yet let go of. + /// + /// Read by the crash-residue sweep before it removes anything + /// (`BoardStore.sweepCommentTrashResidue(inCard:)`): "content referenced by a live coarse step on + /// the board stack is a step's backing, not residue" (13 ▸ Interaction with the trash, ruled + /// 2026-07-31). Both directions count, because both are live: a step sitting on the redo stack has + /// not retired, and 13's own release condition — "undone-and-superseded" — is the moment it does. + /// So does a step a stale skip stranded, which is crossable in *neither* direction and holding all + /// the same: the skip-purge decoupling put its backing's release at the session's end, and a sweep + /// that could not see it would take the release back (`NativeHistoryProvider.strandedSteps`). + /// + /// A *point-in-time* answer, computed on demand rather than cached: the stacks are the truth, and + /// a second copy of this could only ever be a stale one. + var backedContent: Set { get } +} + +extension HistoryProviding { + + /// **A substrate that keeps no steps holds no backing** — the git provider's honest answer + /// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides + /// the close flush" true on Pro), and a test fake's. + /// + /// A default on the requirement rather than a free function, so the dispatch is the substrate's: + /// an implementation that *does* keep steps overrides it and every caller through `any + /// HistoryProviding` sees the override. + public var backedContent: Set { [] } } diff --git a/Kanban/History/NativeHistoryProvider.swift b/Kanban/History/NativeHistoryProvider.swift index a2f8dd6..e306786 100644 --- a/Kanban/History/NativeHistoryProvider.swift +++ b/Kanban/History/NativeHistoryProvider.swift @@ -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 { + var backing: Set = [] + 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) diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 0a770d4..7004a96 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -36,6 +36,79 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable { } } +/// **The one-shot failure class's second shape** (02-architecture.md § The banner surface, settled +/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation +/// failure** — the operation named in the user's words plus the underlying error, phrasing still +/// BannerCenter's — because failures rank by what they are, not by which error vocabulary threw +/// them". +/// +/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git +/// operations stay off the closed `WriteOperation` vocabulary — only the banner tier learns the +/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a +/// checkout is not one. What the two shapes *do* share is everything the strip cares about — the +/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle — which is why +/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`). +/// +/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is +/// the mirror of this one's: a failed restore used to post a `LossBanner` — the nearest class with a +/// free-form message — which read as warning-tone content-didn't-arrive when what actually happened +/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2) +/// pull/push … presents in the error tone at the failure rank, never as a warning-tone loss row." +public struct GitFailureBanner: Identifiable, Sendable, Equatable { + public let id: UUID + /// Which operation didn't happen — a closed vocabulary, so the sentence is composed here rather + /// than carried in from the git layer (`GitOperation`). + public let operation: GitOperation + /// The underlying error, verbatim — libgit2's own message, the free-form tail the phrasing rules + /// allow ("could not write to '…': Permission denied"). Never the banner's verb. + public let reason: String + /// When it failed — the sort key for "newest first within a class", shared with the write + /// failures it ranks beside. + public let occurredAt: Date + + public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) { + self.id = id + self.operation = operation + self.reason = reason + self.occurredAt = occurredAt + } +} + +/// **The git operations the banner can name** — the closed vocabulary behind `GitFailureBanner`, +/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner +/// owns all user-facing phrasing and localization from that vocabulary", and an operation added +/// without a sentence to say about it is a compile-time hole rather than a silent default +/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively). +/// +/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2) +/// pull/push" — the first three are here because they have producers today +/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence +/// when the remote half ships. +/// +/// No associated values: a branch switch's target is in the in-progress row the failure replaces +/// ("Switching to 'main'…"), and a failure that named it again would be the only member of this +/// vocabulary carrying a payload — a case can grow one the day a sentence genuinely needs it. +public enum GitOperation: Sendable, Equatable, CaseIterable { + case undo + case redo + case branchSwitch + + /// **Add-git, when the form that asked has been dismissed** (06-history-undo.md ▸ Interaction with + /// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer + /// arrives, the failure falls back to the one-shot banner above — inline is the primary surface, + /// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback. + case addGit + + /// The pair ⌘Z and ⇧⌘Z map to — so a caller that knows a `HistoryDirection` (the provider's + /// crossing) never has to spell the mapping itself, and never gets it backwards. + public static func restore(_ direction: HistoryDirection) -> GitOperation { + switch direction { + case .undo: .undo + case .redo: .redo + } + } +} + /// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner /// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28) — /// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin. @@ -144,7 +217,7 @@ public struct InProgressOperation: Identifiable, Sendable { /// One row in a window's banner strip. /// -/// The seven cases are the whole vocabulary of 02-architecture.md § The banner surface, and they +/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they /// divide into three lifecycles that the view renders differently and that the ordering rule /// treats as classes: /// @@ -152,11 +225,13 @@ public struct InProgressOperation: Identifiable, Sendable { /// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin, /// "a condition is never dismissed while it is still true". Each leaves when the thing it /// describes stops being true. -/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something that already -/// happened, so only the user can clear it. `loss` shares this lifecycle deliberately (settled -/// 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the harm, so -/// it never auto-expires" is the same reasoning that keeps a one-shot from evaporating unread, -/// aimed at a row that isn't an error at all. +/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something +/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as +/// `oneShot` in a second shape (settled 2026-07-31) — same tone, same rank, same lifecycle, a +/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately +/// (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the +/// harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating +/// unread, aimed at a row that isn't an error at all. /// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps /// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`). /// @@ -173,6 +248,10 @@ public enum BannerRow: Identifiable, Sendable { case reloadBreakage(BoardLoadError) /// A write that did not happen. Dismissable, error tone. case oneShot(OneShotBanner) + /// A git operation that did not happen — an undo restore, a branch switch, and (pro-m2) a pull + /// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure + /// class's second shape (settled 2026-07-31, see `GitFailureBanner`). + case gitFailure(GitFailureBanner) /// Content that didn't arrive though nothing failed — folders skipped from a Finder drop, the /// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it, /// above the ambient notices below it (settled 2026-07-28, see `LossBanner`). @@ -192,6 +271,7 @@ public enum BannerRow: Identifiable, Sendable { case .readOnlyLock: "read-only-lock" case .reloadBreakage: "reload-breakage" case let .oneShot(banner): "one-shot:\(banner.id.uuidString)" + case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)" case let .loss(loss): "loss:\(loss.id.uuidString)" case .historySuspended: "history-suspension" case let .inProgress(operation): "operation:\(operation.id.uuidString)" @@ -201,7 +281,10 @@ public enum BannerRow: Identifiable, Sendable { public var tone: BannerTone { switch self { - case .readOnlyLock, .reloadBreakage, .oneShot: .error + // A git operation that failed is an action that didn't happen, so it takes the failure + // tone with the write failures it ranks beside — "never as a warning-tone loss row" + // (02-architecture.md § The banner surface, settled 2026-07-31). + case .readOnlyLock, .reloadBreakage, .oneShot, .gitFailure: .error case .historySuspended, .loss: .warning case .inProgress, .signpost: .info } @@ -221,6 +304,7 @@ public enum BannerRow: Identifiable, Sendable { case let .readOnlyLock(reason): BannerCenter.headline(for: reason) case let .reloadBreakage(error): BannerCenter.headline(for: error) case let .oneShot(banner): BannerCenter.headline(for: banner.error) + case let .gitFailure(banner): BannerCenter.headline(for: banner) case let .loss(loss): loss.message case let .historySuspended(suspension): BannerCenter.headline(for: suspension) case let .inProgress(operation): operation.label @@ -234,6 +318,7 @@ public enum BannerRow: Identifiable, Sendable { public var dismissID: UUID? { switch self { case let .oneShot(banner): banner.id + case let .gitFailure(banner): banner.id case let .loss(loss): loss.id case let .signpost(signpost): signpost.id case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil @@ -300,11 +385,12 @@ public enum BannerRowControl: Identifiable, Sendable { /// /// ### What lives here and what does not /// -/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the -/// history suspension, in-progress operations, and passive signposts. It deliberately does **not** -/// hold the read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying -/// them here would create a second place for them to be stale. `BoardStore.bannerRows` composes both -/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a +/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write +/// and git-operation), loss rows, the history suspension, in-progress operations, and passive +/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage — those +/// are `BoardStore`'s truths, and copying them here would create a second place for them to be +/// stale. `BoardStore.bannerRows` composes both halves through +/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a /// *pure function* precisely so the precedence rule can be tested without a store, a window, or a /// filesystem. /// @@ -315,7 +401,10 @@ public enum BannerRowControl: Identifiable, Sendable { /// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no /// `default`**, so a Writer operation added without a sentence to say about it is a compile-time /// hole rather than a silent fallback. The same rule covers locks, breakage, and the history -/// suspension: their user-facing lines are here, not on the error types. +/// suspension: their user-facing lines are here, not on the error types. **And the git operations +/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin — "the operation +/// named in the user's words plus the underlying error, phrasing still BannerCenter's" — which is +/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row. /// /// ### One center per window, not per board /// @@ -334,6 +423,17 @@ public final class BannerCenter { /// turn — still order deterministically. public private(set) var oneShots: [OneShotBanner] = [] + /// The failure class's git-shaped half, newest first like `oneShots` — a failed undo restore, + /// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see + /// `GitFailureBanner`). + /// + /// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no + /// payload in common — one carries the closed `WriteOperation` vocabulary and a path, the other + /// a git operation and libgit2's message — and every reader of a write failure would have to + /// start asking whether it was one. They meet where the design says they meet, in the strip's + /// order: `rows(...)` merges them into one precedence class, newest first across both. + public private(set) var gitFailures: [GitFailureBanner] = [] + /// Newest first, like `oneShots` — content that didn't arrive though nothing failed, /// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see /// `LossBanner`). @@ -361,6 +461,19 @@ public final class BannerCenter { oneShots.insert(OneShotBanner(error: error), at: 0) } + /// **Records a git operation that didn't happen** — the failure class's second shape (settled + /// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly + /// (06-history-undo.md ▸ Interaction with external writers), a branch switch that could not run + /// (06 ▸ Branch switching), and the remote pair when pro-m2 wires them. + /// + /// `reason` is the underlying error and nothing else — libgit2's own message, or the app's own + /// diagnosis of a repository it could not read. **Every user-facing word is composed here** + /// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's + /// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip. + public func postGitFailure(_ operation: GitOperation, reason: String) { + gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), at: 0) + } + /// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see /// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with. public func postLoss(_ message: String) { @@ -575,20 +688,23 @@ public final class BannerCenter { nonisolated static let mixedTrashDragMessage = "Cards and lanes leave the trash separately \u{2014} restore one kind at a time" - /// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names - /// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" - /// are different promises and a row that offers one must never quietly do the other. + /// Removes a dismissable row: a one-shot failure in either shape, a loss row, or a signpost. + /// **An id that names an in-progress operation is ignored** rather than ending it, because + /// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do + /// the other. public func dismiss(_ id: UUID) { oneShots.removeAll { $0.id == id } + gitFailures.removeAll { $0.id == id } losses.removeAll { $0.id == id } signposts.removeAll { $0.id == id } } - /// Removes every dismissable row — one-shots, losses, and signposts alike. The strip's own - /// "clear all" affordance later; today it is what a window uses when it re-homes its rows + /// Removes every dismissable row — both failure shapes, losses, and signposts alike. The strip's + /// own "clear all" affordance later; today it is what a window uses when it re-homes its rows /// elsewhere (m6). public func dismissAllDismissableRows() { oneShots.removeAll() + gitFailures.removeAll() losses.removeAll() signposts.removeAll() } @@ -684,10 +800,17 @@ public final class BannerCenter { /// a condition and a one-shot are not comparable by recency in any way a user would read as /// order — the condition's `since` is when it *started* being true, not when it happened — /// so they are ordered by kind, and recency orders only the one-shots among themselves. + /// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31): + /// "failures rank by what they are, not by which error vocabulary threw them", so a failed + /// undo posted a second ago sits above a failed move from a minute ago and below one from a + /// second before it. Ties — two rows sharing a `Date` to the microsecond — put the write + /// failure first; the two shapes are posted from different call sites, so a tie is an + /// accident of the clock rather than an order anyone can read. /// - /// `signposts` carries a default because its producer is m6's card window and nothing posts one - /// today; every other class has a live producer and is spelled out at every call site — `losses` - /// included, since a Finder drop that skipped folders already posts one (`postSkippedFolders`). + /// `signposts` and `gitFailures` carry defaults: the first because its producer is m6's card + /// window, the second because a center that hosts no git surface (a card window's own) can never + /// hold one. Every other class is spelled out at every call site — `losses` included, since a + /// Finder drop that skipped folders already posts one (`postSkippedFolders`). public nonisolated static func rows( lock: ReadOnlyLockReason?, breakage: BoardLoadError?, @@ -695,7 +818,8 @@ public final class BannerCenter { losses: [LossBanner], suspension: HistorySuspension?, operations: [InProgressOperation], - signposts: [InfoSignpost] = [] + signposts: [InfoSignpost] = [], + gitFailures: [GitFailureBanner] = [] ) -> [BannerRow] { var rows: [BannerRow] = [] @@ -708,10 +832,13 @@ public final class BannerCenter { rows.append(.reloadBreakage(breakage)) } - let ordered = newestFirst(oneShots) - rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot)) + let ordered = newestFirst(oneShots, by: \.occurredAt) + rows.append(contentsOf: failureRank( + writes: ordered.filter { !$0.isAttachmentImport }, + git: newestFirst(gitFailures, by: \.occurredAt) + )) - rows.append(contentsOf: newestFirst(losses).map(BannerRow.loss)) + rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss)) if let suspension { rows.append(.historySuspended(suspension)) @@ -722,31 +849,47 @@ public final class BannerCenter { return rows } - /// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run - /// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which - /// `post(_:)` already maintains newest-first — so a tie renders in the order it was posted - /// rather than in whatever order the sort happened to leave. - private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] { - banners - .enumerated() - .sorted { lhs, rhs in - lhs.element.occurredAt == rhs.element.occurredAt - ? lhs.offset < rhs.offset - : lhs.element.occurredAt > rhs.element.occurredAt + /// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and + /// the git-operation failures, merged into the one precedence class they share — "failures rank + /// by what they are, not by which error vocabulary threw them". + /// + /// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the + /// write failure first, which is arbitrary and says so (`rows(...)`). + private nonisolated static func failureRank( + writes: [OneShotBanner], + git: [GitFailureBanner] + ) -> [BannerRow] { + var merged: [BannerRow] = [] + var writes = writes[...] + var git = git[...] + while let write = writes.first, let failure = git.first { + if failure.occurredAt > write.occurredAt { + merged.append(.gitFailure(failure)) + git = git.dropFirst() + } else { + merged.append(.oneShot(write)) + writes = writes.dropFirst() } - .map(\.element) + } + merged.append(contentsOf: writes.map(BannerRow.oneShot)) + merged.append(contentsOf: git.map(BannerRow.gitFailure)) + return merged } - /// The same stable newest-first ordering as the overload above, for loss rows — the two classes - /// share a lifecycle, and `postLoss` already maintains newest-first on insertion the way - /// `post(_:)` does. - private nonisolated static func newestFirst(_ losses: [LossBanner]) -> [LossBanner] { - losses + /// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop + /// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every + /// `post…` maintains newest-first on insertion — so a tie renders in the order it was posted + /// rather than in whatever order the sort happened to leave. + /// + /// One function over a date key rather than one per class: the three dismissable classes order + /// by exactly the same rule, and three copies of it were three places for it to drift. + private nonisolated static func newestFirst(_ rows: [Row], by occurredAt: KeyPath) -> [Row] { + rows .enumerated() .sorted { lhs, rhs in - lhs.element.occurredAt == rhs.element.occurredAt + lhs.element[keyPath: occurredAt] == rhs.element[keyPath: occurredAt] ? lhs.offset < rhs.offset - : lhs.element.occurredAt > rhs.element.occurredAt + : lhs.element[keyPath: occurredAt] > rhs.element[keyPath: occurredAt] } .map(\.element) } @@ -764,6 +907,39 @@ public final class BannerCenter { return cause.isEmpty ? action : "\(action) — \(cause)" } + /// The user-facing line for a git operation that didn't happen: what the app could not do, then + /// why — the write failure's shape exactly (settled 2026-07-31), because the two are one class. + /// + /// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer + /// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the + /// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action + /// clause alone rather than trailing a dash into nothing. + public nonisolated static func headline(for failure: GitFailureBanner) -> String { + let action = actionPhrase(for: failure.operation) + let cause = trimmed(failure.reason) + return cause.isEmpty ? action : "\(action) — \(cause)" + } + + /// **Exhaustive by construction — no `default`**, the `WriteOperation` rule applied to the git + /// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile. + /// + /// The undo pair is named by **the command the user pressed** — the skipped-step row's rule + /// (`skippedStepMessage`), and for its reason: ⌘Z is the gesture, and "Couldn't restore an + /// earlier state" would describe machinery the user has no model of. The branch switch is named + /// by the control they used (the popover's branch picker) and stays plural — "branches" — rather + /// than naming the target, which the in-progress row this failure replaces already said + /// ("Switching to 'main'…") and which `GitOperation` deliberately doesn't carry. + private nonisolated static func actionPhrase(for operation: GitOperation) -> String { + switch operation { + case .undo: "Undo failed" + case .redo: "Redo failed" + case .branchSwitch: "Couldn't switch branches" + // Named by the control the user pressed, like the pair above — "Add Git" is the button, and + // the row is only ever seen by someone who pressed it and looked away. + case .addGit: "Couldn't add git to this board" + } + } + /// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a /// sentence here fails to compile, which is the settled contract ("a new Writer operation /// without a banner rendering is a compile-time hole, not a silent default"). @@ -853,6 +1029,14 @@ public final class BannerCenter { // touched, and nothing is lost: the board works exactly as well without the guide, which // is why every *refusal* to write it is a log line and only a real I/O failure gets here. "Couldn't update the agent guide" + case .seedGitignore: + // **The agent guide's sentence, one file over**, and for its reasons: `.gitignore` is a + // courtesy the app writes once, most users will never open it, and nothing about the + // board's own files is at stake — a board without one simply excludes nothing, which is + // how every board behaved until 2026-07-31. It names the file rather than a role because + // this one *does* have a name users know from git, and "the ignore list" would be the + // app inventing a word for something already called something. + "Couldn't write this board's .gitignore" case let .displaceClaimedName(name): // **The name, quoted, and what the app wanted with it** — the failure's mirror of the // success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 6b3b29a..e570ba1 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -370,7 +370,8 @@ public final class BoardStore: HealHost { losses: banners.losses, suspension: banners.historySuspension, operations: banners.operations, - signposts: banners.signposts + signposts: banners.signposts, + gitFailures: banners.gitFailures ) } @@ -4055,10 +4056,60 @@ public final class BoardStore: HealHost { } } + // MARK: - The board's noise definition + + /// Puts the seeded `.gitignore` on a board that has none, and does nothing at all to a board + /// that has one — 06-history-undo.md ▸ Repository hygiene's whole scheduling ("a board missing + /// the file gains it by scheduled heal at open (the guide-refresh cadence)", re-ruled + /// 2026-07-31). + /// + /// **`refreshAgentGuide()`'s twin, deliberately**, down to the shape of this method: the file is + /// a board-root courtesy the app owns, its defect is *presence* rather than anything a tree walk + /// could report, and the decision is one `lstat`. What differs is that there is no version to + /// compare and no displacement to make — a name already held by anything at all is left exactly + /// as it stands (`BoardWriter.seedGitignoreIfAbsent`), including by a directory, because an + /// unreadable noise definition costs the board nothing but the exclusions it never had. + /// + /// **Silent.** No banner row, on the guide's reasoning exactly: a courtesy file the user did not + /// create and may not know exists, whose absence changed nothing they can see. Only a genuine I/O + /// failure reaches the strip, through `performWrite`. + /// + /// **The memo is armed before the attempt and cleared on success** (`HealScheduler`'s steps 4 and + /// 6), which is what makes a foreign deletion healable: the picture "missing" is restored by the + /// delete, and a standing memo would make that deletion the one thing this could not answer — + /// while a failing write still gets exactly one attempt per changed picture rather than one per + /// reload. "Deletion is answered by re-seeding" is 06's own wording. + /// + /// **It registers no undo step**, like every heal: nobody asked for it. + public func seedGitignore() { + let root = rootURL + // An empty signature is the engine's resting state and costs no bracket — which matters here + // more than anywhere, because this heal runs at every open and reload tail of every board, + // and a bracket schedules a reload whether or not anything was written. + let signature: Set = IntegrityRules + .node(at: root.appendingPathComponent(IntegrityRules.gitignoreFileName)) == nil + ? ["gitignore:missing"] + : [] + + heals.run(.missingGitignore, signature: signature, on: self) { () throws(BoardWriteError) -> Void in + // The disk re-verify is the Writer's own: a file that appeared under us — another window, + // an agent, a clone finishing — makes this a no-op rather than an overwrite. + try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root) + } + } + /// **Every scheduled heal, in order** — the engine's two seams call exactly this /// (02-architecture.md ▸ Components ▸ HealScheduler: "fires uniformly at the reload tail and at /// registry acquire, closing today's asymmetry where tombstone migration never fires at open"). /// + /// **The `.gitignore` seed goes before the relocation, and that ordering is load-bearing in the + /// same way** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-31): the seed *is* + /// the noise gate the relocation obeys, so a pass that relocated first would act, once, on a + /// board whose noise definition it was about to write. The window it closes is narrow by + /// construction — the walk that produced this pass's work list ran before either heal, so the + /// seed's own patterns cannot filter it until the next load, and both of them name hidden files + /// the carve-out never touches anyway — but the order costs nothing and states the dependency. + /// /// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's /// migration mints `/.trash/`, which cannot be created while a file or symlink holds that /// name — so a migration attempted ahead of the displacement fails *and arms its memo against an @@ -4084,6 +4135,7 @@ public final class BoardStore: HealHost { /// re-armed by the reload the others' writes produce, so none can see another's work half-done. public func runScheduledHeals() { displaceClaimedNames() + seedGitignore() relocateLooseCardFiles() migrateLegacyTombstones() remintDuplicateIdentities() diff --git a/Kanban/LiveStore/BoardStoreComments.swift b/Kanban/LiveStore/BoardStoreComments.swift index fae22a8..01dcd97 100644 --- a/Kanban/LiveStore/BoardStoreComments.swift +++ b/Kanban/LiveStore/BoardStoreComments.swift @@ -261,6 +261,12 @@ extension BoardStore { /// old stale-after-close skip scenario cannot arise". What reaches the board is the close step /// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is /// keeping alive (`registerCardSession`). + /// + /// The `.present(trashed)` half of the undo's expectations is also this step's **backing claim**, + /// read off it by `HistoryStep.backing(declaredBy:)` and carried into the coarse step by the fold: + /// it is what tells the next window open's residue sweep that the folder in `comments/.trash/` is + /// a live step's, not a dead session's leftovers (13 ▸ Interaction with the trash, ruled + /// 2026-07-31). @discardableResult public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool { guard let card = commentSubject(id) else { return false } @@ -309,10 +315,16 @@ extension BoardStore { /// The call site moved with that re-ruling and this method did not change: the window's close no /// longer purges on its own, it hands this work to the coarse close step as that step's /// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board - /// stack — undone-and-superseded, dropped, gone stale — or when the board session ends. On a git - /// board the step is never kept, so the retirement fires at the close flush, which is where the - /// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next - /// card-window open is unchanged. + /// stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session + /// ends. A **stale skip is not a clean exit** and does not purge (the decoupling ruled + /// 2026-07-31): that step's backing survives to session end instead, where `clear()` retires it + /// and this method finally runs. On a git board the step is never kept, so the retirement fires at + /// the close flush, which is where the purge always ran there ("purge rides the close flush"). + /// + /// **It empties the folder whole, and its two callers are exactly the moments that is right**: a + /// close that registered no step (nothing took the hold), and the retirement of the step that + /// did (the hold has just ended). The *sweep* cannot assume either, which is why it purges per + /// entry behind the ownership gate — `sweepCommentTrashResidue(inCard:)`. /// /// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be: /// invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with the @@ -327,10 +339,49 @@ extension BoardStore { /// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue /// sweeps at the next card-window open, armed-then-cleared like every heal memo"). /// - /// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash - /// is empty (which is every open on a board that closed cleanly, and costs no bracket at all), - /// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear - /// on success. + /// ### Residue is what no live step owns — the stack is asked first + /// + /// The ruling of 2026-07-31 (13-native-undo.md ▸ Interaction with the trash; § Enhanced schema's + /// "residue defined as content no live coarse step owns"): + /// + /// > "residue is defined by the purge-deferral condition itself: `comments/.trash/` content + /// > referenced by a live coarse step on the board stack is a step's **backing, not residue** — + /// > the open-time sweep consults the stack and skips owned content, re-arming when the owning + /// > step leaves the stack (which is exactly when the deferred purge wanted to run; one condition, + /// > two consumers). **Reopening a window can therefore never destroy its prior session's undo + /// > backing.** Unowned content sweeps as before." + /// + /// The defect it closes is the reopen: close a window that deleted a comment (the coarse step + /// registers, its undo restores from `comments/.trash/`), open the same card again, and this sweep + /// used to empty the trash out from under a step still sitting on the board's stack — leaving a + /// ⌘Z that could only skip. + /// + /// So the gate is `HistoryProviding.backedContent`, and it is the same fact the deferred purge + /// waits on, asked as an inventory instead of as a moment. Nothing here has to *re-arm* by hand: + /// when the owning step retires, its retirement runs the deferred purge and the content is gone; + /// and a sweep that found nothing left to do rests, which clears the memo (`HealScheduler` step 1) + /// so a later picture is a fresh one. + /// + /// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled + /// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the skip + /// is exactly when the user may want to inspect what the collision left". Nothing here says so — + /// that is `backedContent`'s answer, and keeping it there is what makes the pair one condition + /// read twice rather than two conditions kept in step by hand. + /// + /// ### Entries, not the container + /// + /// Which is why this no longer calls `BoardWriter.purgeCommentTrash(inCard:)` — that empties the + /// folder whole, and the whole folder is exactly what this may not assume it owns. The per-entry + /// primitive is the same one an undone create removes its folder with, over the entries the + /// listing already narrowed to identity shape; a hand-editor's stray in there keeps the verbatim + /// posture either way. + /// + /// The rest is the same six steps every scheduled heal gets, through the same engine: **rest** + /// when there is nothing unowned (which is every open on a board that closed cleanly, and costs no + /// bracket at all), defer under a read-only lock, compare the signature, arm before attempting, + /// one bracket, clear on success. The signature is computed from **the entries actually purged**, + /// so the picture the memo remembers is the work that was attempted rather than everything the + /// trash happened to hold. /// /// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is /// the app's own leftovers from a session that died; there is nothing here a user could act on. @@ -341,13 +392,21 @@ extension BoardStore { public func sweepCommentTrashResidue(inCard id: ItemID) { guard let card = commentSubject(id) else { return } let folder = card.folder - let residue = CommentThread.trashedCommentIDs(inCard: folder) + // The board's stack, never a window's: a window's own fine comment steps die with the window + // that owns them, and the window this sweep runs for has not made a gesture yet. What can + // outlive a close is the coarse step the close folded the session into, and that is here. + let backed = history?.backedContent ?? [] + let residue = CommentThread.trashedCommentIDs(inCard: folder).filter { commentID in + !backed.contains(.trashedComment(commentID, inCard: id)) + } heals.run( .commentTrashResidue, signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }), on: self ) { () throws(BoardWriteError) -> Void in - _ = try BoardWriter.purgeCommentTrash(inCard: folder) + for commentID in residue { + try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder)) + } } } diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index c746408..d75c9e7 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -76,8 +76,15 @@ enum AgentGuide { /// defect was a missing second line, and the parser's complaint pointed at the *last* key it /// swallowed rather than the title. The guide now teaches long values as one long line, says /// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside - /// the unquoted colon in Hard rules. - static let version = 9 + /// the unquoted colon in Hard rules. **v10 makes `order` and `schema` optional below the board + /// root** (01-storage-format.md § Frontmatter and § Ordering, re-ruled 2026-07-31): the guide's + /// whole point is that filing a card must need nothing but the schema + /// (08-agent-integration.md's masterplan requirement), and until now that was untrue — a card + /// needed a rank, and a rank needed a scan of every sibling in the lane. The guide now teaches + /// the zero-read minimum (`mkdir` plus one `index.md`, no `order`, no `schema`; it lands at the + /// lane's bottom and the app stamps a real rank on its first touch) while still teaching + /// *writing* `order` as the way to control position, which is the only way to control it. + static let version = 10 // MARK: - The version marker @@ -367,7 +374,8 @@ enum AgentGuide { ## Reading the board - Lanes run left→right by ascending `order`; cards top→bottom by ascending - `order` within their lane. Ties break by folder name. + `order` within their lane. Ties break by folder name. An item with no + `order` sorts after every item that has one — see Creating a card. - Lane titles carry the workflow semantics (e.g. To Do → In Progress → Done). Read the board's and lanes' index.md bodies for descriptions and per-lane policy before deciding where a card belongs. @@ -376,13 +384,15 @@ enum AgentGuide { ## Frontmatter - All levels: `schema` (required, always `1`), `title` (optional — an item - without one renders as untitled, so give cards real titles), `created` and - `modified` (ISO-8601 with timezone, e.g. `2026-07-24T18:00:00Z`), - `background` (color), `icon` (SF Symbol name), `iconColor` (color, tints - `icon`). Lanes and cards additionally require `order` (a number; floats are - fine). Lanes may set `width` (integer ≥ 1, multiplier of the standard lane - width). + All levels: `schema` (always `1`; **required at the board's own `index.md`**, + optional below it — a lane or card without one is read as schema 1), `title` + (optional — an item without one renders as untitled, so give cards real + titles), `created` and `modified` (ISO-8601 with timezone, e.g. + `2026-07-24T18:00:00Z`), `background` (color), `icon` (SF Symbol name), + `iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number; + floats are fine) — **optional, and the way to control position**: an item + without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the + standard lane width). **Quote any `title` containing a colon** — `title: Fix: the thing` is invalid YAML; write `title: "Fix: the thing"`. The same goes for any value @@ -418,9 +428,7 @@ enum AgentGuide { ## Creating a card - 1. Pick the lane folder. Compute `order`: bottom of the lane = max existing - card `order` + 1024; top = min − 1024; between two cards = their - midpoint. (Empty lane: any number, conventionally 1024.) + 1. Pick the lane folder. 2. Create a folder named a fresh lowercase UUID: `id=$(uuidgen | tr 'A-Z' 'a-z')`. 3. Write `/$id/index.md` (timestamp: `date -u +%FT%TZ`): @@ -438,8 +446,31 @@ enum AgentGuide { The card's content — any Markdown. ``` + **`order` is what places the card, and computing it means reading the + lane**: bottom of the lane = max existing card `order` + 1024; top = + min − 1024; between two cards = their midpoint. (Empty lane: any number, + conventionally 1024.) Write it whenever the position matters. + + **You can also file a card without reading the lane at all.** The minimum + legal card is a `mkdir` and one `index.md` containing nothing but a title — + no `order`, no `schema`: + + ```markdown + --- + title: Short imperative card title + --- + The card's content. + ``` + + It lands at the bottom of the lane (an item with no `order` sorts after + every item that has one; two such items sort by folder name), and the app + writes a real `order` into it the next time it rewrites that file. Prefer + the full frontmatter above — `kind`, the timestamps and `modified-by` are + all worth having — but when you are filing into a 200-card lane and the + position doesn't matter, the short form costs one write and no reads. + Creating a lane is the same one level up (body optional; `kind: lane`; - `order` ranks lanes left→right). + `order` ranks lanes left→right, and is optional in the same way). **Always write `kind`** at creation — `kind: card`, `kind: lane`, `kind: board` at board root. Depth already says what an item is on the @@ -508,10 +539,11 @@ enum AgentGuide { ## Hard rules (the app fails loudly on violations) - - Frontmatter must parse as YAML; `schema` (plus `order` on lanes and - cards) is required. Keep `schema: 1`. The classic violations are an - unquoted colon in a title and a quoted value left unclosed across a - line break (see Frontmatter above). + - Frontmatter must parse as YAML. The board's own `index.md` must carry + `schema: 1`; everywhere else `schema` and `order` are optional and a + missing one is read, never refused. Never write a `schema` other than + `1`. The classic violations are an unquoted colon in a title and a + quoted value left unclosed across a line break (see Frontmatter above). - Files must be UTF-8 without BOM. - Never create a card folder without an `index.md`. - Never rename UUID folders. diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 316ef25..c2730fe 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -30,9 +30,9 @@ import os /// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face /// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card /// face), and the snapshot is where it reads from. -/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is -/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules, -/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in +/// - `looseFileNames(in:ignoring:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. +/// This is the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ +/// Rules, settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in /// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a /// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a /// Writer-mediated app write the store schedules off the snapshot @@ -40,6 +40,10 @@ import os /// /// Everything else about a card folder's contents remains outside this loader's business. /// +/// **A third read, once per walk rather than per card**: the board root's `.gitignore`, which is the +/// noise gate the detection above obeys (§ Rules, ruled 2026-07-31 — `ignoreRules(atBoardRoot:)`). A +/// file it matches is not a defect at all; it keeps the ordinary stray posture. +/// /// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never /// followed, whether it points to a file or a directory — this loader does not resolve /// cross-volume or cyclic trees. @@ -49,7 +53,7 @@ import os /// `/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders /// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined /// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside -/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the +/// the lanes, parsing its UUID-shaped children through the same `schema`/`order` rulebook and the /// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never /// warns; absent, the trash is simply empty. /// @@ -142,8 +146,41 @@ public enum BoardLoader: Sendable { /// table itself is `IntegrityRules`', with every other reserved name. static let reservedCardChildNames: Set = IntegrityRules.reservedCardChildNames + /// The board's noise definition, at board root (01-storage-format.md § Fractal layout ▸ Rules, + /// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names. + static let gitignoreFileName = IntegrityRules.gitignoreFileName + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader") + // MARK: - The noise gate + + /// **The board-root `.gitignore`, parsed** — the one file the loose-file carve-out consults + /// before calling anything relocatable (01-storage-format.md § Fractal layout ▸ Rules, ruled + /// 2026-07-31: "`.gitignore` is the noise gate"). + /// + /// Read **once per walk**, at the top of `load`, and handed down to every card: a board with 400 + /// cards reads this file once, not 400 times. `nil` when the board has none — and `nil` and an + /// empty file mean exactly the same thing to every caller (nothing is excluded), which is the + /// ruling's own escape hatch working by construction rather than by a branch. + /// + /// **Board root only.** "Nested `.gitignore` files are ordinary strays the heal never consults" — + /// a `.gitignore` inside a lane or a card is a file like any other, and one inside a card is + /// itself a hidden entry the carve-out never touches. + /// + /// Reading it here does not make the loader impure: this is a read like `index.md`'s, on a file + /// the walk's result depends on. Nothing is written, and an unreadable or non-UTF-8 file answers + /// `nil` — "cannot tell" reads as "excludes nothing", which is the direction that loses no file + /// (an unrelocated stray stays exactly where it is; the alternative would silently move things + /// the user meant to be noise). + static func ignoreRules(atBoardRoot root: URL) -> GitignoreRules? { + guard let data = try? Data(contentsOf: root.appendingPathComponent(gitignoreFileName)), + let text = String(data: data, encoding: .utf8) + else { + return nil + } + return GitignoreRules(parsing: text) + } + // MARK: - Entry point public static func load( @@ -157,7 +194,10 @@ public enum BoardLoader: Sendable { throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex) } let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName) - let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName) + // **The root's own `schema` stays required** (01-storage-format.md § Malformed input, + // re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on the + // board that does not read as 1 when absent. + let boardSchema = try validatedRootSchema(in: boardDocument, path: indexFileName) var warnings: [LoadWarning] = [] func warn(_ warning: LoadWarning) { @@ -182,8 +222,18 @@ public enum BoardLoader: Sendable { // `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the // line exists to be findable later rather than to be noticed now ("no banner, no behavior // change"). - func noteCoercions(in document: FrontmatterDocument, at path: String) { - let fields = document.coercedFields + // + // `plus` carries the records only the rulebook can make: `order` and `schema` below the root + // (re-ruled 2026-07-31 — the optional-key readings). They cannot come from + // `document.coercedFields`, which reads *present* fields that had no sensible reading and by + // construction knows nothing about a key that is simply absent, nor about which level the + // file sits at. + func noteCoercions( + in document: FrontmatterDocument, + at path: String, + plus extra: [CoercedField] = [] + ) { + let fields = extra + document.coercedFields guard !fields.isEmpty else { return } defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields))) for field in fields { @@ -214,6 +264,11 @@ public enum BoardLoader: Sendable { noteCoercions(in: boardDocument, at: indexFileName) + // **The noise gate, read once for the whole walk** (01-storage-format.md § Fractal layout ▸ + // Rules, ruled 2026-07-31): the board's `.gitignore` is what decides which loose files are + // work and which are ordinary strays, and it is one file per board — not one per card. + let ignoreRules = ignoreRules(atBoardRoot: boardRoot) + // Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide // identity dedupe below decides which folders render at all, and a `Lane` is built only on // the far side of that decision, because a `Lane` carrying a withheld card would be exactly @@ -236,11 +291,18 @@ public enum BoardLoader: Sendable { let lanePath = laneName + "/" + indexFileName let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath) - let laneSchema = try validatedSchema(in: laneDocument, path: lanePath) - let laneOrder = try validatedOrder(in: laneDocument, path: lanePath) - noteCoercions(in: laneDocument, at: lanePath) + // Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema` reads + // as 1, a missing or unusable `order` as append-at-end. Both readings are coerce-tier — + // recorded here, logged, and acted on by nothing until the file's next Writer touch. + let laneSchema = try resolvedSchema(in: laneDocument, path: lanePath) + let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument) + noteCoercions( + in: laneDocument, + at: lanePath, + plus: [laneSchema.coerced, laneOrder.coerced].compactMap { $0 } + ) - var cards: [Card] = [] + var walkedCards: [WalkedCard] = [] for cardURL in try directoryCandidates(in: laneURL) { let cardName = cardURL.lastPathComponent let cardRelPath = laneName + "/" + cardName @@ -254,7 +316,7 @@ public enum BoardLoader: Sendable { } let card = try parseCard(at: cardURL, path: cardRelPath) - noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName) + noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions) // **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules, // extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing @@ -269,8 +331,11 @@ public enum BoardLoader: Sendable { ) } - // Noticed, never acted on: the relocation is the store's, through the Writer. - let loose = looseFileNames(in: cardURL) + // Noticed, never acted on: the relocation is the store's, through the Writer. What + // the board's `.gitignore` excludes never becomes a defect at all — it keeps the + // stray posture, so there is nothing here for the store to schedule and nothing for + // a banner to announce. + let loose = looseFileNames(in: cardURL, ignoring: ignoreRules) if !loose.isEmpty { defects.append(.looseCardFiles(LooseCardFiles( laneID: ItemID(rawValue: laneName), @@ -292,9 +357,18 @@ public enum BoardLoader: Sendable { logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/") } - cards.append(card) + walkedCards.append(card) } + // **The append-at-end reading, settled per container** (01-storage-format.md § Ordering, + // re-ruled 2026-07-31): every card's rank is the one it wrote down, or — where it wrote + // none — a materialized rank past every ordered sibling, ordered among the other + // order-less ones by folder name. Settled here, over the lane's whole card list, because + // that is the smallest scope in which "past every ordered sibling" is a fact. + let cardOrders = Ranks.resolvedOrders( + of: walkedCards, stored: \.storedOrder, name: { $0.id.rawValue }) + let cards = zip(walkedCards, cardOrders).map { $0.rendered(order: $1) } + // **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md // § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads // live with the key ignored — no migration machinery, no key-strip write, no notice @@ -307,20 +381,34 @@ public enum BoardLoader: Sendable { walkedLanes.append(WalkedLane( name: laneName, - schema: laneSchema, - order: laneOrder, + schema: laneSchema.schema, + storedOrder: laneOrder.order, document: laneDocument, cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }) )) } - var trash: [Card] = [] - var trashedLanes: [TrashedLane] = [] - /// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to - /// the identity rule, whatever the snapshot splits it into. Carried with the two keys the - /// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is - /// sorted by `modified` descending since 2026-07-31. - var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = [] + // The strip's own append-at-end reading — the card rule one level up, and the reason the + // lane loop above carried a *stored* order: a lane with no rank sorts past every ranked one, + // which cannot be known until the last lane folder has been read. + for (index, order) in Ranks.resolvedOrders( + of: walkedLanes, stored: \.storedOrder, name: \.name + ).enumerated() { + walkedLanes[index].order = order + } + + /// Every trash entry as the walk read it, kind decided but rank not yet — the `WalkedCard` + /// intermediate's job in the flat container, in a tuple because the split into the snapshot's + /// two arrays happens on the far side of the append-at-end reading. + var walkedTrash: [( + id: ItemID, + kind: IntegrityRules.ObjectKind, + schema: Int, + storedOrder: Double?, + heldCards: Int, + attachments: [String], + document: FrontmatterDocument + )] = [] var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:] for entryURL in trashCandidates(in: boardRoot) { let entryName = entryURL.lastPathComponent @@ -333,14 +421,18 @@ public enum BoardLoader: Sendable { warn(.missingIndex(path: entryRelPath)) continue } - // The two kinds are validated identically — `schema` and `order` are required of both - // (`IntegrityRules.requiresOrder`) — so the strict parse happens once, before the - // discriminator, and a malformed entry fails fast whichever kind it turns out to be. + // The two kinds are read identically — the same optional-`schema`/optional-`order` + // rulebook below the root — so the parse happens once, before the discriminator, and a + // schema newer than this app fails fast whichever kind the entry turns out to be. let entryPath = entryRelPath + "/" + indexFileName let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath) - let schema = try validatedSchema(in: document, path: entryPath) - let order = try validatedOrder(in: document, path: entryPath) - noteCoercions(in: document, at: entryPath) + let schema = try resolvedSchema(in: document, path: entryPath) + let order = IntegrityRules.resolvedOrder(in: document) + noteCoercions( + in: document, + at: entryPath, + plus: [schema.coerced, order.coerced].compactMap { $0 } + ) // **The trash's discriminator, applied where the flat container needs it** // (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted @@ -362,30 +454,60 @@ public enum BoardLoader: Sendable { ) let id = ItemID(rawValue: entryName) trashKinds[id] = kind - trashEntries.append((id: id, title: document.title.value, modified: document.modified.value)) - switch kind { + // **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit + // showing its title and held-card count). The count is the same listing the shape + // fallback asks for, so a `kind: lane` entry pays for exactly one directory read and a + // kindless one pays for none extra — and a card pays for its attachment listing only, + // which is why each side is read under its own arm rather than unconditionally. + // + // Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` treats + // both as unrecognized and answers by shape — so the non-lane arm is the card answer and + // nothing else. + let isLane = kind == .lane + walkedTrash.append(( + id: id, + kind: kind, + schema: schema.schema, + storedOrder: order.order, + heldCards: isLane ? children().count : 0, + attachments: isLane ? [] : attachmentNames(in: entryURL), + document: document + )) + } + + // The trash's own append-at-end reading, over the container as one flat list. `order` decides + // nothing about where a trash row *sits* — that is `modified`'s job since 2026-07-31 — but + // every entry carries a rank for its eventual restore, and an entry that carries none reads + // like every other order-less file rather than getting a container-specific rule of its own. + var trash: [Card] = [] + var trashedLanes: [TrashedLane] = [] + /// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to + /// the identity rule, whatever the snapshot splits it into. Carried with the two keys the + /// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is + /// sorted by `modified` descending since 2026-07-31. + var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = [] + let trashOrders = Ranks.resolvedOrders( + of: walkedTrash, stored: { $0.storedOrder }, name: { $0.id.rawValue }) + for (entry, order) in zip(walkedTrash, trashOrders) { + let document = entry.document + trashEntries.append(( + id: entry.id, title: document.title.value, modified: document.modified.value)) + switch entry.kind { case .lane: - // **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit - // showing its title and held-card count). The count is the same listing the shape - // fallback asks for, so a `kind: lane` entry pays for exactly one directory read and - // a kindless one pays for none extra. trashedLanes.append(TrashedLane( - id: id, - schema: schema, + id: entry.id, + schema: entry.schema, title: document.title, modified: document.modified, order: order, - heldCards: children().count, + heldCards: entry.heldCards, document: document )) case .card, .board, .comment: - // Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` - // treats both as unrecognized and answers by shape — so this arm is the card answer - // and nothing else. trash.append(Card( - id: id, - schema: schema, + id: entry.id, + schema: entry.schema, title: document.title, created: document.created, modified: document.modified, @@ -395,7 +517,7 @@ public enum BoardLoader: Sendable { icon: document.icon, iconColor: document.iconColor, order: order, - attachments: attachmentNames(in: entryURL), + attachments: entry.attachments, document: document )) } @@ -579,7 +701,13 @@ public enum BoardLoader: Sendable { private struct WalkedLane { let name: String let schema: Int - let order: Double + /// The rank the file actually carries, `nil` where it carries none this app can use. + let storedOrder: Double? + /// The append-at-end reading, filled in once every sibling lane has been read + /// (`Ranks.resolvedOrders(of:stored:name:)` — 01-storage-format.md § Ordering, re-ruled + /// 2026-07-31). A `var` on a private walk value for the same reason the type exists at all: + /// the container settles it, and the `Lane` is built on the far side of that. + var order: Double = 0 let document: FrontmatterDocument /// Already in display order — the traversal the dedupe's last tie-break is stated in. let cards: [Card] @@ -648,35 +776,67 @@ public enum BoardLoader: Sendable { return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) } } - /// One card folder read into a `Card` — the lane walk's card parse. + /// One card as the walk read it, before its container's ranks resolved — a `Card` minus the + /// append-at-end reading, which cannot be settled until every sibling's stored `order` is known + /// (01-storage-format.md § Ordering, re-ruled 2026-07-31). `WalkedLane`'s shape, one level down + /// and for the same kind of reason: a value the container decides is not a value the item can + /// carry while it is still being read. + private struct WalkedCard { + let id: ItemID + let schema: Int + /// The rank the file actually carries, `nil` where it carries none this app can use — the + /// input to `Ranks.resolvedOrders(of:stored:name:)`. + let storedOrder: Double? + let attachments: [String] + let document: FrontmatterDocument + /// This card's coerce-tier records for the strict fields, which only the rulebook can make + /// (a missing key leaves no trace in `document.coercedFields`). + let coercions: [CoercedField] + + var title: FieldValue { document.title } + var isDeleted: Bool { !document.deleted.isMissing } + + func rendered(order: Double) -> Card { + Card( + id: id, + schema: schema, + title: document.title, + created: document.created, + modified: document.modified, + modifiedBy: document.modifiedBy, + deleted: document.deleted, + background: document.background, + icon: document.icon, + iconColor: document.iconColor, + order: order, + attachments: attachments, + document: document + ) + } + } + + /// One card folder read into a `WalkedCard` — the lane walk's card parse. /// /// The trash's own walk reads its entries inline instead, because the container is flat and its - /// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict - /// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a - /// function that has already decided what it is reading. + /// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their + /// `schema`/`order` rulebook (`IntegrityRules`, one rule) rather than sharing a function that + /// has already decided what it is reading. /// /// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`. /// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has. - private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card { + private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> WalkedCard { let cardPath = path + "/" + indexFileName let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath) - let schema = try validatedSchema(in: document, path: cardPath) - let order = try validatedOrder(in: document, path: cardPath) + let schema = try resolvedSchema(in: document, path: cardPath) + let order = IntegrityRules.resolvedOrder(in: document) - return Card( + return WalkedCard( id: ItemID(rawValue: cardURL.lastPathComponent), - schema: schema, - title: document.title, - created: document.created, - modified: document.modified, - modifiedBy: document.modifiedBy, - deleted: document.deleted, - background: document.background, - icon: document.icon, - iconColor: document.iconColor, - order: order, + schema: schema.schema, + storedOrder: order.order, attachments: attachmentNames(in: cardURL), - document: document + document: document, + coercions: [schema.coerced, order.coerced].compactMap { $0 } ) } @@ -782,7 +942,7 @@ public enum BoardLoader: Sendable { /// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load /// is a pure function of the tree and stays one. /// - /// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons: + /// Five exclusions, three of them `attachmentNames(in:)`' own and for its reasons: /// /// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested /// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory @@ -795,14 +955,32 @@ public enum BoardLoader: Sendable { /// would surface it in a card's attachment list — the loudest possible way to be wrong about /// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed /// residue out of the relocation. + /// + /// **This survived the noise-gate ruling** (2026-07-31), which retired "nothing hardcoded" + /// exclusions in favour of the seeded `.gitignore`, because it is not a noise heuristic and + /// never was a `.DS_Store` special case: `attachmentNames(in:)` skips hidden entries too, so a + /// relocated hidden file would land in a folder whose listing can never show it — the move + /// would take a file the user can see in Finder beside `index.md` and put it somewhere the app + /// is structurally unable to surface. The carve-out exists to put a card's files where the app + /// *shows* them; a hidden file has no such destination. What the ruling retires is the app + /// having a second opinion about *which* visible files are noise, and it never had one. /// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively. + /// - **Whatever the board's `.gitignore` excludes** (§ Rules, ruled 2026-07-31 — the noise + /// gate): "a file matching the board-root `.gitignore` … keeps the ordinary stray posture: + /// skipped, preserved verbatim, logged, never relocated, never announced". Matched against the + /// file's **board-relative** path (`//`), because that is the path git would + /// match and because an anchored pattern (`/notes.txt`) has to mean the board root rather than + /// every card in it. `rules` is `nil` on a board that carries no such file, which excludes + /// nothing — the pre-ruling behaviour, and the same answer an empty file gives. /// /// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice /// the store posts names files the way the board would sort them. /// /// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses /// to open, and "nothing to relocate" is the safe reading of "cannot tell". - static func looseFileNames(in cardFolder: URL) -> [String] { + /// `rules` has no default on purpose: the gate is the ruling, and a caller that could forget to + /// pass it would be a second, quieter definition of what counts as noise. + static func looseFileNames(in cardFolder: URL, ignoring rules: GitignoreRules?) -> [String] { guard let entries = try? FileManager.default.contentsOfDirectory( at: cardFolder, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], @@ -811,19 +989,40 @@ public enum BoardLoader: Sendable { return [] } + let cardPath = boardRelativeCardPath(of: cardFolder) return entries .filter { url in guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()), - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, values.isSymbolicLink != true else { return false } - return values.isRegularFile == true && values.isSymbolicLink != true + // The noise gate. Logged rather than silent, because "skipped, preserved verbatim, + // logged" is the stray posture this file is being *given*, and a file that stays put + // for a reason nobody can see is the one the user files a bug about. + let name = url.lastPathComponent + guard rules?.isIgnored(relativePath: cardPath + "/" + name) == true else { return true } + logger.info( + "\(cardPath, privacy: .public)/\(name, privacy: .public): matched by the board's \(gitignoreFileName, privacy: .public) — left where it is, not relocated" + ) + return false } .map(\.lastPathComponent) .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } + /// A card folder's board-relative path — `/` — read off the URL rather than passed + /// in, so the one caller that has no walk behind it (the Writer's import-boundary normalization) + /// gets the same answer the walk would have given. + /// + /// The depth is fixed by the schema: a card is `//` and nothing else + /// (`BoardWriter.checkIsCardFolder` enforces exactly this shape before any relocation), so two + /// components are the whole of it. + private static func boardRelativeCardPath(of cardFolder: URL) -> String { + cardFolder.deletingLastPathComponent().lastPathComponent + "/" + cardFolder.lastPathComponent + } + /// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** — /// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name /// shape gates level detection"). This is *the* identity predicate, and it is deliberately @@ -943,11 +1142,13 @@ public enum BoardLoader: Sendable { /// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the /// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte"). /// - /// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its - /// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not - /// newer than this app) and `order` (present, well-formed) — the two fields a card must carry. - /// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional, - /// unknown keys are the whole point of the outlet, and the body is free text. + /// **Exactly the checks `load(boardRoot:)` runs on a card**, in its order and through its own + /// functions: decode + parse (`parseDocument`), then `schema` at the below-the-root reading + /// (missing reads as 1; malformed, or newer than this app, still refuses). Nothing card-shaped + /// is checked beyond that, because nothing else *is*: `title` is optional, **`order` is optional + /// too** since 2026-07-31 — a card applied without one lands at its lane's bottom and gains a + /// rank on its next touch — unknown keys are the whole point of the outlet, and the body is free + /// text. /// /// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical /// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping @@ -968,13 +1169,18 @@ public enum BoardLoader: Sendable { } /// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸ - /// Components). These two forward so the walk above reads as it always did. - private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int { - try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema) + /// Components). These forward so the walk above reads as it always did. + private static func validatedRootSchema( + in document: FrontmatterDocument, path: String + ) throws(BoardLoadError) -> Int { + try IntegrityRules.validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema) } - private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double { - try IntegrityRules.validatedOrder(in: document, path: path) + /// `schema` below the root — missing reads as 1, coerce-tier (re-ruled 2026-07-31). + private static func resolvedSchema( + in document: FrontmatterDocument, path: String + ) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) { + try IntegrityRules.resolvedSchema(in: document, path: path, supportedSchema: supportedSchema) } } @@ -1153,11 +1359,23 @@ public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertibl /// frontmatter block that isn't a mapping. `line` is 1-based within the file when the /// underlying error carries one. case unparseableYAML(message: String, line: Int?) + /// **The board root's own missing `schema`** — the this-really-is-a-board gate (re-ruled + /// 2026-07-31). Below the root a missing `schema` reads as 1 instead, coerce-tier. case missingSchema case malformedSchema(raw: String) /// `schema` is present, valid, and greater than this app's `supportedSchema`. case schemaNewerThanApp(found: Int) - /// `order` is required on lanes and cards, never on the board itself. + + /// **Retired, and nothing throws these any more** (01-storage-format.md § Ordering and + /// § Malformed input, re-ruled 2026-07-31): below the board root a missing, null, non-numeric + /// or non-finite `order` reads as append-at-end — coerce-tier, logged, bytes preserved + /// (`IntegrityRules.resolvedOrder`) — and the board root never carried a rank to begin with. + /// + /// They stay in the vocabulary rather than being deleted because this enum *is* the + /// load-failure surface every banner, announcement and decision-surface row is written + /// against, and a reason that can no longer occur costs those surfaces nothing while removing + /// one would rewrite them for a rule that changed underneath, not for a shape they render + /// differently. case missingOrder case malformedOrder(raw: String) /// The board root exists but is a file, not a directory. diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index e5973d6..dfd4a92 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -169,11 +169,19 @@ public struct Lane: Identifiable, Sendable, Equatable { public let icon: FieldValue public let iconColor: FieldValue - /// Rank among lanes, ascending = left-to-right. Strict, per the frontmatter table's - /// required field: `BoardLoader` fails the whole load (`missingOrder`/`malformedOrder`) - /// rather than construct a `Lane` with a bad `order` — by the time one exists here it is - /// always valid. Validity is the loader's job, not this type's; that is why it is a plain - /// `Double` and not `FieldValue`. + /// Rank among lanes, ascending = left-to-right — **the reading, not necessarily the key** + /// (01-storage-format.md § Ordering, re-ruled 2026-07-31: `order` is optional below the board + /// root, and "missing or unusable reads as append-at-end"). + /// + /// A lane whose `index.md` carries a usable `order` reads as that number. One that carries none + /// — no key, an explicit null, a non-numeric or non-finite value — reads as a rank past every + /// ordered sibling, materialized by `Ranks.resolvedOrders(of:stored:name:)` and tie-broken among + /// the other order-less lanes by folder name. Either way this is a plain, finite `Double` by the + /// time a `Lane` exists, which is why it is not a `FieldValue`: resolving the reading is + /// the loader's job, and the snapshot carries answers rather than shapes. + /// + /// The value is real enough to write down, and the Writer does exactly that on the file's first + /// touch (`IntegrityRules.OnTouchHeal.rankStamped`) — so the stamp changes nothing on screen. public let order: Double /// Width multiplier ≥ 1 (default 1 when missing or malformed). Lenient — it styles layout, @@ -222,8 +230,10 @@ public struct Card: Identifiable, Sendable, Equatable { public let icon: FieldValue public let iconColor: FieldValue - /// Rank within its lane, ascending = top-to-bottom. Strict — see `Lane.order`'s doc - /// comment; the same reasoning applies here. + /// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See + /// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters + /// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all + /// (08-agent-integration.md), and it lands at the bottom of its lane. public let order: Double /// The card's attachment file names — **flat: top-level regular files only, in Finder @@ -291,8 +301,10 @@ public struct TrashedLane: Identifiable, Sendable, Equatable { /// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites /// no `order` at all, so this is still the strip position a restore would want and the value the /// undo of a restore puts back. It is deliberately *not* what orders this row in the column - /// (`modified` is), and the same required, strictly validated field a live lane carries - /// (`Lane.order`). + /// (`modified` is), and it is the same optional-but-always-read field a live lane carries + /// (`Lane.order`): an entry that reaches the trash without one reads as append-at-end over the + /// container's own entries, which is inert here by construction — a restore computes a fresh + /// rank at its destination. public let order: Double /// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards"). diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index e6cf793..bfbc55c 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -116,10 +116,33 @@ public enum BoardWriter: Sendable { let operation = operation.withTitle(document.title.value) try checkEditable(document, at: indexURL, operation: operation) + let orderBefore = document.order edits(&document) - // After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps, - // which outrank everything for their own reason. - IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder)) + + let itemKind = kind ?? derivedKind(ofItemFolder: folder) + + // **The inline sibling stamp** (01-storage-format.md § Ordering, re-ruled 2026-07-31): a + // write that rewrites this item's *rank* is placement math landing, and placement math ranks + // against the append-at-end reading of any order-less sibling — so those siblings are made to + // hold that reading, here, inside the caller's own bracket and commit. Sequence-preserving + // and a no-op on every board this app wrote; see `stampAppendAtEndOrders`. + // + // Two exclusions. `.renumberChildren` *is* the rescale — it rewrites every sibling with a + // fresh rank already, and re-entering here would recurse. Anything that is not a lane or a + // card has no rank ladder to be placed in: a board root's siblings are other people's + // folders, and a comment's are chronology. + if document.order != orderBefore, operation != .renumberChildren, itemKind == .lane || itemKind == .card { + try stampAppendAtEndOrders( + of: folder.deletingLastPathComponent(), excluding: folder, operation: operation) + } + + // After `edits`, so a caller that wrote its own `kind` or `order` is left alone, and before + // the stamps, which outrank everything for their own reason. + IntegrityRules.healOnTouch( + &document, + kind: itemKind, + rank: appendAtEndRank(ofItemFolder: folder, kind: itemKind) + ) // The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates // the container's own arrangement and leaves both provenance keys exactly as it found them — // a standing `modified-by` survives a reorder, which is the pairing 01 spells out. @@ -166,6 +189,51 @@ public enum BoardWriter: Sendable { } } + /// **The rank an order-less file reads as** — the on-touch stamp's value (01-storage-format.md + /// § Ordering, re-ruled 2026-07-31: "The rank materializes on touch: the first Writer rewrite of + /// the file stamps a real rank"). + /// + /// It is `Ranks.resolvedOrders`' answer for this folder among its siblings — the very rank the + /// board is already rendering it at — so the stamp writes down what the reading already said and + /// nothing moves on screen. The loader and this share the function, which is what makes that + /// true rather than merely intended. + /// + /// `nil` — stamp nothing — in four cases, each of them "this file has no rank to have": + /// + /// - **The kind has no ladder**: a board root (no siblings), a comment ("Ordering is chronology, + /// not ranks"), or a folder whose position has no answer at all. + /// - **The sibling scan failed** (an unreadable neighbour, a parse error). An on-touch heal is + /// opportunistic by construction — it rides a host write that has its own reason to succeed — + /// so a failure here declines to stamp rather than failing that write. The next touch tries + /// again. + /// - **The item is not among its own visible siblings**: it carries a legacy `deleted:` key, and + /// a tombstone is inert to ordering (§ Deletion). The migration relocates it; a rank invented + /// on the way would be a fact about a position it no longer has. + /// + /// Never called on a file that already carries a usable `order` — `healOnTouch` takes it as an + /// `@autoclosure`, so the directory of reads below is paid for only where it is the answer. + private static func appendAtEndRank( + ofItemFolder folder: URL, + kind: IntegrityRules.ObjectKind? + ) -> Double? { + switch kind { + case .lane, .card: break + case .board, .comment, nil: return nil + } + // The operation is only what a failure would be *named*, and this one discards failures. + guard let siblings = try? visibleSiblings( + of: folder.deletingLastPathComponent(), + operation: .renumberChildren, + requireEditable: false + ) else { + return nil + } + let identity = IntegrityRules.canonicalIdentity(folder.lastPathComponent) + return siblings + .first { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) == identity }? + .order + } + // MARK: - Atomic replace /// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**, @@ -241,6 +309,11 @@ public enum BoardWriter: Sendable { /// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one /// (§ Document packaging, "Extension-less board folders still open") — this call never /// looks at `rootURL`'s extension. + /// - **The board is born with its noise definition** (06-history-undo.md ▸ Repository hygiene, + /// re-ruled 2026-07-31: "Board creation writes the minimal seed"): `.gitignore` lands beside + /// `index.md`, after it, so the file that makes a folder a board is written first and a failure + /// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a + /// creation into a folder that somehow already carries one leaves it alone. public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) { let operation = WriteOperation.createBoard let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName) @@ -264,6 +337,7 @@ public enum BoardWriter: Sendable { at: indexURL, operation: operation ) + try seedGitignoreIfAbsent(atBoardRoot: rootURL) } /// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under @@ -308,7 +382,16 @@ public enum BoardWriter: Sendable { ) throws(BoardWriteError) -> ItemID { try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation) - let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) + // **The inline sibling stamp, before the append** (01-storage-format.md § Ordering, re-ruled + // 2026-07-31). A create is placement math like any other — "after the current visible + // siblings" is a rank *relative to* the last of them — so an order-less sibling has to hold + // the rank this append is computed against, or the new item would file itself above a card + // that re-materializes past it on the next load. It is the one placement that cannot ride + // `updateIndex`'s hook: this path mints a file rather than rewriting one. + // + // `excluding: nil` — the new identity does not exist yet, so there is nothing to skip, and + // the container the stamp reads is exactly the one the append is about to be computed over. + let siblings = try stampAppendAtEndOrders(of: parentFolder, excluding: nil, operation: operation) let order = Ranks.append(toVisible: siblings.map(\.order)) let folder = try mintUUIDFolder(in: parentFolder, operation: operation) @@ -428,6 +511,66 @@ public enum BoardWriter: Sendable { } } + // MARK: - The board's noise definition + + /// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene, re-ruled + /// 2026-07-31: "Board creation writes the minimal seed — `.DS_Store` plus the writer's temp + /// pattern (`.*.lanework-*`)"). + /// + /// Two lines, and each one earns its place: + /// + /// - **`.DS_Store`** — the Finder writes one into every folder a user looks at, and on a board + /// that means one per lane and one per card, each churning as icons and window positions move. + /// On a git board it would be committed by the whole-tree stage, forever, under the user's own + /// name; on every board it is now also the plainest thing the loose-file heal must not treat as + /// a card's attachment. + /// - **`.*.lanework-*`** — this file's own temp pattern, and the one entry the app is entitled to + /// have an opinion about: `atomicReplace` writes `..lanework-` beside its target, + /// so a crashed write's residue is the app's litter rather than the user's file. The two must + /// stay in step; changing the temp name means changing this line. + /// + /// Nothing else. "Fine-tuning what counts as noise over time means fine-tuning the seed" — and + /// after the seed the file is the user's, never edited by the app again. + /// + /// Files the app creates end with LF (01-storage-format.md § Encoding and line endings). + public static let gitignoreSeed = ".DS_Store\n.*.lanework-*\n" + + /// **Puts the seed on a board that has no `.gitignore`, and does nothing to one that has** + /// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31: "`.gitignore` seeded on every + /// board, never touched after"). + /// + /// The three properties are the feature: + /// + /// - **Only when the name is free.** `IntegrityRules.node(at:)` — `lstat`, never `fileExists` — + /// so a *directory* or a **dangling symlink** wearing the name is left alone too, rather than + /// written through. A board that already carries a `.gitignore` (from a template, a clone, the + /// user, or an earlier open) is left byte for byte alone, and an **empty** one is honored + /// exactly as it stands: it is the ruling's own escape hatch for wanting no exclusions. + /// - **Never an edit.** There is no merge, no append, no "and while we're here". The app writes + /// this file at most once per board and then reads it forever. + /// - **Universal.** Board creation calls it, template instantiation calls it, and a scheduled + /// heal calls it at every open for boards born before the ruling — repo-nested boards included, + /// because the file serves the heal there, not any app-managed git. + /// + /// A foreign deletion is answered by re-seeding on the next open, which is what makes the + /// scheduled heal's memo (armed before the attempt, cleared on success) load-bearing rather than + /// tidy: the picture "missing" must stay healable. + /// + /// - Returns: whether this call wrote the file — `false` when the name was already taken, which + /// is a success with nothing to do (the heal's disk re-verify, and the reason a second open + /// costs no write). + @discardableResult + public static func seedGitignoreIfAbsent(atBoardRoot root: URL) throws(BoardWriteError) -> Bool { + let url = root.appendingPathComponent(IntegrityRules.gitignoreFileName) + guard IntegrityRules.node(at: url) == nil else { return false } + try atomicReplace(text: gitignoreSeed, at: url, operation: .seedGitignore) + // Heal-marked, on the agent guide's reasoning exactly: this is app-initiated work on a file + // the user did not create, and its commit is its own (06-history-undo.md ▸ Commit messages — + // a non-snapshot path composes "Update '.gitignore'"). + EchoLedger.current?.markHeal(at: url) + return true + } + // MARK: - Renumber /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback @@ -442,7 +585,10 @@ public enum BoardWriter: Sendable { /// rewriting the lane. /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order` /// ascending, folder name breaking ties) — the same rule the loader sorts by, so a - /// renumber is guaranteed to be sequence-preserving: nothing visibly moves. + /// renumber is guaranteed to be sequence-preserving: nothing visibly moves. An order-less + /// sibling takes part on its append-at-end reading (`visibleSiblings`) and comes out of the + /// batch with a real rank, which makes the rescale the heavier sibling of + /// `stampAppendAtEndOrders` rather than a second rule. /// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no /// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only /// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of @@ -478,20 +624,27 @@ public enum BoardWriter: Sendable { /// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an /// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning /// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them. - /// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming - /// that sibling's file, before anything is written — the same discover-before-you-write - /// guarantee `renumberVisibleChildren`'s batch depends on. + /// - **A missing or unusable `order` is no longer a failure** (01-storage-format.md § Ordering, + /// re-ruled 2026-07-31): the sibling reads as append-at-end, exactly as the loader reads it + /// (`Ranks.resolvedOrders(of:stored:name:)`), and `order` carries that reading while + /// `hasStoredOrder` records whether the file actually says so. The scan is still strict about + /// everything it was strict about before — an unreadable or unparseable sibling still fails the + /// whole operation before anything is written, which is the discover-before-you-write guarantee + /// `renumberVisibleChildren`'s batch depends on. /// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that /// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an /// unwritable sibling halfway through the batch), the creates pass `false` — a create /// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders /// normally (01-storage-format.md § Frontmatter) must not block creating a new item /// beside it. + /// + /// The returned ranks are **in listing order, not display order**; every caller that needs the + /// latter sorts through `Ranks.sortedForDisplay`, as they always did. private static func visibleSiblings( of parentFolder: URL, operation: WriteOperation, requireEditable: Bool - ) throws(BoardWriteError) -> [(folder: URL, order: Double)] { + ) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] { let candidates: [URL] do { candidates = try BoardLoader.directoryCandidates(in: parentFolder) @@ -503,7 +656,7 @@ public enum BoardWriter: Sendable { ) } - var visible: [(folder: URL, order: Double)] = [] + var visible: [(folder: URL, storedOrder: Double?)] = [] for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } @@ -513,25 +666,88 @@ public enum BoardWriter: Sendable { if requireEditable { try checkEditable(document, at: indexURL, operation: operation) } + visible.append((folder: folder, storedOrder: IntegrityRules.resolvedOrder(in: document).order)) + } - switch document.order { - case .missing: - throw BoardWriteError( - operation: operation, - path: indexURL.path, - reason: .unreadable(message: "missing required 'order' field") - ) - case let .malformed(raw): - throw BoardWriteError( - operation: operation, - path: indexURL.path, - reason: .unreadable(message: "malformed 'order' field: \(raw)") - ) - case let .valid(order): - visible.append((folder: folder, order: order)) + // **The loader's own reading, computed from the same bytes** — which is what lets a placement + // the app computed against the *snapshot* be made true on disk by simply stamping it + // (`stampAppendAtEndOrders(of:excluding:operation:)`). + let resolved = Ranks.resolvedOrders( + of: visible, stored: { $0.storedOrder }, name: { $0.folder.lastPathComponent }) + return zip(visible, resolved).map { + (folder: $0.folder, order: $1, hasStoredOrder: $0.storedOrder != nil) + } + } + + // MARK: - The append-at-end reading's inline stamp + + /// **Writes down the append-at-end reading for every order-less child of `parentFolder`** + /// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "placement math that must rank an item + /// *relative to* an order-less sibling stamps that sibling inline, inside the gesture's bracket + /// and commit — the midpoint-renumber precedent"). + /// + /// ## Why a placement cannot skip it + /// + /// An order-less file renders at a rank nobody wrote down. Placement math ranks the arriving item + /// against that rank — a midpoint between two order-less siblings, an append past the last one — + /// and the answer is only *true on disk* if the siblings hold the ranks the math assumed. Without + /// the stamp the arriving item would be filed against ranks that evaporate on the next load, and + /// the order-less siblings would re-materialize past it: the card lands in the wrong place, + /// silently. Stamping is what closes that gap, and it is the same shape as the renumber that + /// closes the midpoint-exhaustion gap — inline, inside the caller's write bracket, composing no + /// event of its own. + /// + /// ## Why nothing moves when it lands + /// + /// The value written is `Ranks.resolvedOrders`' answer for that file — the rank the board was + /// *already rendering it at*. So this is sequence-preserving by construction, exactly like a + /// renumber, and it is idempotent: run it twice and the second call finds nothing to do. + /// + /// ## Cost, stated + /// + /// One `index.md` read per visible sibling, on any write that rewrites a rank — the same read the + /// reload following that write performs anyway. A board this app wrote has no order-less children + /// at all, so the pass writes nothing; it is the hand-filed and agent-filed cases that pay. + /// + /// ## The reading is the container's, and the caller must not narrow it + /// + /// `excluding` skips the **write**, never the reading: the ranks stamped are resolved over the + /// container exactly as the loader resolved them, placed item included. Narrowing the resolution + /// would re-base the materialized ranks against a different maximum than the snapshot the + /// placement was computed from, and the two would disagree about where the order-less siblings + /// sit. The corollary is that a placement whose item is **not yet in the container** — a + /// cross-parent move, a copy, a create — must stamp *before* the folder lands, so that what this + /// reads is what the loader read. Each of those call sites does. + /// + /// - Parameter excluding: the item the caller is about to place, when it is already a child here. + /// It is skipped because its own rank is about to be written by the caller anyway, and stamping + /// it first would be two writes to one file inside one gesture. + /// - Returns: the container's visible siblings and their resolved ranks — the same values a + /// re-scan would answer, so a caller that needs them next (the creates' append) can use these + /// rather than reading the directory twice. + @discardableResult + static func stampAppendAtEndOrders( + of parentFolder: URL, + excluding placed: URL?, + operation: WriteOperation + ) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] { + // `requireEditable: false`: only the order-less siblings are rewritten, so only *they* owe + // the pre-flight — a flow-mapping sibling that already carries a rank must not block a + // placement it has nothing to do with. `updateIndex` refuses an uneditable one loudly, which + // is the honest answer for a file the placement genuinely depends on. + let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) + let excluded = placed.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) } + + for sibling in siblings where !sibling.hasStoredOrder { + guard IntegrityRules.canonicalIdentity(sibling.folder.lastPathComponent) != excluded else { continue } + // `.renumberChildren`: this *is* a rescale of the container's own arrangement — order-only, + // so no `modified` stamp and no `modified-by` clear (the reorders-don't-stamp rule), and + // the one operation `updateIndex` does not re-enter this pass for. + try updateIndex(inItemFolder: sibling.folder, operation: .renumberChildren) { document in + document.set(FrontmatterKeys.order, to: .double(sibling.order)) } } - return visible + return siblings } // MARK: - Move @@ -655,6 +871,14 @@ public enum BoardWriter: Sendable { return MoveResult(id: ItemID(rawValue: sourceName), reminted: []) } + // **Before the folder lands** (01-storage-format.md § Ordering, re-ruled 2026-07-31): the + // destination's order-less children are stamped with the reading they had *without* this + // item in the container — which is the reading the caller's placement math was computed + // against, since the snapshot it read did not hold this item here either. Doing it after the + // move would re-base those ranks against the arriving item's own (foreign-lane) rank. + // The same-parent degenerate reorder above needs none of this: it never leaves the container, + // so `updateIndex`'s own hook stamps against the unchanged reading. + try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation) let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot) @@ -870,6 +1094,10 @@ public enum BoardWriter: Sendable { // contract refuses here, where nothing has been materialized and there is nothing to undo. try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation) + // `moveItem`'s pre-arrival stamp, for the same reason: the copy is not in the destination's + // reading yet, and the placement it is about to take was computed against a reading that did + // not hold it (01-storage-format.md § Ordering, re-ruled 2026-07-31). + try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation) let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) let rootName = freshUUIDName(in: destinationParent, avoiding: []) @@ -2064,13 +2292,33 @@ public enum BoardWriter: Sendable { /// user's own paste made and the app immediately cleaned up. /// /// A card with nothing loose is one directory listing and no write at all. + /// + /// **The destination board's noise gate applies here too** (01-storage-format.md § Fractal layout + /// ▸ Rules, ruled 2026-07-31): the arriving card is already at its final path, so its files are + /// the destination board's to have an opinion about — and a paste that swept a file the very next + /// walk would have left alone would be the app disagreeing with itself one gesture apart. The + /// rules are read from the board root the card's own path implies (`checkIsCardFolder` proves the + /// `//` shape before anything moves), which for a batch means one small file + /// read per card; the lane-level face below reads it once for the whole lane. @discardableResult public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] { + try normalizeLooseFiles( + inCard: cardFolder, + ignoring: BoardLoader.ignoreRules(atBoardRoot: boardRoot(ofCard: cardFolder)) + ) + } + + /// The rules-in-hand form, so a batch reads the board's `.gitignore` once rather than per card. + @discardableResult + static func normalizeLooseFiles( + inCard cardFolder: URL, + ignoring rules: GitignoreRules? + ) throws(BoardWriteError) -> [ImportedAttachment] { // `healMarked: false` — an **inline** heal batches with the gesture that triggered it // (01-storage-format.md § Validation and healing), so its paths are the paste's, not a // heal's, and splitting them out would name a commit for work the user asked for. try relocateLooseFiles( - BoardLoader.looseFileNames(in: cardFolder), + BoardLoader.looseFileNames(in: cardFolder, ignoring: rules), inCard: cardFolder, healMarked: false ) @@ -2084,13 +2332,23 @@ public enum BoardWriter: Sendable { /// reached: the carve-out is card-level and one level deep, exactly as 01 states it. @discardableResult public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] { + let rules = BoardLoader.ignoreRules(atBoardRoot: laneFolder.deletingLastPathComponent()) var moved: [ImportedAttachment] = [] for card in childCandidates(of: laneFolder) { - moved.append(contentsOf: try normalizeLooseFiles(inCard: card)) + moved.append(contentsOf: try normalizeLooseFiles(inCard: card, ignoring: rules)) } return moved } + /// The board root a card folder implies — `//` with two components taken off. + /// + /// Purely arithmetic on the path, and deliberately not a validation: the callers that reach it + /// run `checkIsCardFolder` on the same URL, and a folder that is not a card has no board root to + /// derive anyway — the rules read from wherever this points would simply find no file. + private static func boardRoot(ofCard cardFolder: URL) -> URL { + cardFolder.deletingLastPathComponent().deletingLastPathComponent() + } + /// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent. /// /// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one @@ -2681,6 +2939,19 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// second phrasing for "couldn't move a file you have never seen" would explain nothing. case agentGuide + /// The board-root `.gitignore` being seeded — at creation, at instantiation, and by the + /// scheduled heal that gives a board born before the ruling the file it now needs + /// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31; `seedGitignoreIfAbsent`). + /// + /// **No payload**, for `.agentGuide`'s reason: there is one such file per board, its name is + /// fixed, and it is not an item with a title. Its own case beside `.agentGuide` rather than a + /// fold into it, on the vocabulary's standing reasoning — one is the agent guide and the other is + /// the board's noise definition, and a banner saying the app "couldn't update the agent guide" + /// after a failed seed would name a file that was never touched. + /// + /// It never describes an *edit*: the app writes this file only when nothing holds the name. + case seedGitignore + /// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink /// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved /// aside by a scheduled heal via the Finder-style rename ladder"). @@ -2804,7 +3075,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { // `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*, // filled in by the caller from the window the gesture came from. case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, - .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, + .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore, .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment, .editComment, .deleteComment, .purgeCommentTrash: self @@ -2867,7 +3138,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { // plain container reason its board-level twin does. case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone, .style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment, - .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName, + .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, + .displaceClaimedName, .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment, .editComment, .deleteComment, .purgeCommentTrash: false @@ -2903,6 +3175,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .renumberChildren: "renumber children" case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" case .agentGuide: "update the agent guide" + case .seedGitignore: "seed the board's .gitignore" case let .displaceClaimedName(name): "move a stray '\(name)' aside" case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title) case let .toggleTask(title): Self.phrase("toggle a checkbox in", title) diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index 12ebae2..25a8a35 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -8,10 +8,14 @@ public struct FrontmatterField: Sendable, Equatable { public let rawValue: String } -/// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`, -/// `order`) from being silently coerced — they fail the load instead. Lenient fields (colors, -/// icons, `width`) coerce where a sensible reading exists (01-storage-format.md § Frontmatter) -/// and only fall back to `.malformed` — rendered as the field's default — when none does. +/// The result of reading a typed field — **the document's reading, before any policy**. Lenient +/// fields (colors, icons, `width`) coerce where a sensible reading exists (01-storage-format.md +/// § Frontmatter) and only fall back to `.malformed` — rendered as the field's default — when none +/// does. Structural fields (`schema`, `order`) have no coercion at all at this layer: a value that +/// is not an integer, or not a finite number, is `.malformed` and nothing here decides what that +/// costs. **What it costs is the rulebook's** (`IntegrityRules`): a malformed `schema` refuses the +/// load, while a malformed or missing `order` below the board root reads as append-at-end +/// (re-ruled 2026-07-31) — one shape, two policies, and this type carries neither. public enum FieldValue: Sendable, Equatable { case missing case valid(Value) @@ -76,11 +80,16 @@ extension FrontmatterDocument { /// "the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or /// a notice". /// - /// **The strict fields are absent, and so is `deleted`.** `schema` and `order` are the *refuse* - /// tier — a malformed one fails the load loudly (`IntegrityRules.validatedSchema`/`validatedOrder`), - /// so there is no silent recovery to leave a trace of. `deleted` is the odd one out on purpose: its - /// rule is *presence, not validity* (a malformed `deleted` still deletes — `Lane`/`Card.isDeleted`), - /// so nothing falls back to a default and the migration reports it under its own defect anyway. + /// **The structural fields are absent, and so is `deleted`.** `schema` and `order` are read + /// through the rulebook rather than here (`IntegrityRules.resolvedSchema`/`resolvedOrder`), + /// because their readings depend on something this layer cannot see — *which level the file sits + /// at*: the root's `schema` refuses, a lane's or card's defaults to 1, and `order` below the root + /// reads as append-at-end (re-ruled 2026-07-31). Those two make their own `CoercedField`s, which + /// the loader concatenates with this list; they also cover the shape a pure document reading + /// cannot produce at all — an **absent** key, which has no `rawText` to record. `deleted` is the + /// odd one out on purpose: its rule is *presence, not validity* (a malformed `deleted` still + /// deletes — `Lane`/`Card.isDeleted`), so nothing falls back to a default and the migration + /// reports it under its own defect anyway. /// /// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case /// and costs one pass over the lenient fields. @@ -103,17 +112,20 @@ extension FrontmatterDocument { return found } - // MARK: - Strict (structure — the loader fails fast on `.malformed`) + // MARK: - Structural (no coercion here — `IntegrityRules` decides what `.malformed` costs) public var schema: FieldValue { read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } } } /// A non-finite reading (`.nan`, `.inf`) has no place in the total order the tie-break and - /// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is the same loud - /// malformed-input rejection as a non-numeric value, not a `.valid(Double.nan)` silently - /// poisoning every comparison downstream. An `Int` reading is always finite, so only the - /// `.double` case needs the check. + /// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is `.malformed` here, + /// never a `.valid(Double.nan)` silently poisoning every comparison downstream. An `Int` reading + /// is always finite, so only the `.double` case needs the check. + /// + /// Below the board root `.malformed` and `.missing` are one reading — append-at-end, coerce-tier + /// (re-ruled 2026-07-31, `IntegrityRules.resolvedOrder`) — but they stay two shapes here, because + /// the coerce record wants the text as written and only one of them has any. public var order: FieldValue { read(FrontmatterKeys.order) { value, _ in switch value { diff --git a/Kanban/Storage/GitignoreRules.swift b/Kanban/Storage/GitignoreRules.swift new file mode 100644 index 0000000..1718fb9 --- /dev/null +++ b/Kanban/Storage/GitignoreRules.swift @@ -0,0 +1,419 @@ +import Foundation + +/// **The board's noise definition, parsed** — a board-root `.gitignore` read as git itself reads +/// one, and asked one question: is this board-relative path ignored? +/// +/// The file outgrew git on 2026-07-31 (01-storage-format.md § Fractal layout ▸ Rules, "`.gitignore` +/// is the noise gate"; 06-history-undo.md ▸ Repository hygiene): it is now the one definition of +/// noise the **loose-file relocation heal** obeys, on every board, git or not. A file matching it +/// keeps the ordinary stray posture — "skipped, preserved verbatim, logged, never relocated, never +/// announced" — and "the exclusion list is exactly that file, nothing hardcoded". On a Pro board the +/// same file governs the committer through libgit2, so ignored noise neither relocates nor commits: +/// one definition, two consumers. This type is the app's half of that, because the load path cannot +/// reach for libgit2 — the free tier opens a board without so much as a `fileExists` under `.git` +/// (12-editions.md ▸ The free tier and `.git`), and the heal runs on boards that have no repository +/// at all. +/// +/// **Pure: no I/O, no filesystem, no `URL`.** It parses text and answers about paths. Reading the +/// bytes is `BoardLoader.ignoreRules(atBoardRoot:)`' job, once per walk — the loader stays a pure +/// function of the tree, and this stays a pure function of the file. +/// +/// ## The semantics, which are git's +/// +/// Implemented from `gitignore(5)`, in its own order: +/// +/// - A **blank line** matches nothing; it exists to space the file out. +/// - A line beginning with `#` is a **comment**. `\#` begins a pattern with a literal `#`. +/// - **Trailing spaces are ignored** unless escaped (`\ `). +/// - A leading `!` **negates**, re-including a path an earlier pattern excluded. `\!` is a literal. +/// - A trailing `/` makes the pattern **directory-only**. +/// - A `/` at the beginning or in the middle **anchors** the pattern to the board root; a pattern +/// with no interior separator matches **at any depth** (`.DS_Store` matches every one of them). +/// - `*` matches any run of characters but never `/`; `?` matches exactly one non-`/` character; +/// `[…]` is a character class, negatable with `!` or `^` and carrying ranges (`[0-9]`). +/// - A path segment that is exactly `**` matches **zero or more** segments: leading `**/` matches at +/// any depth, a trailing `/**` matches everything inside, and `a/**/b` matches `a/b` as well as +/// `a/x/y/b`. Asterisks anywhere else are ordinary `*`s, exactly as git says. +/// - **Last match wins.** The verdict is the last pattern in file order that matched, negations +/// included — which is why the rules are kept in file order and evaluated in it. +/// - **An ignored directory is final.** A path under an excluded directory is excluded whatever a +/// later negation says about it — git does not descend into an ignored directory, so a rule about +/// something inside it is never consulted. `isIgnored(relativePath:isDirectory:)` walks the +/// ancestors for exactly this reason. +/// +/// ## The deliberate divergences, and they are three +/// +/// - **Only this file.** Nested `.gitignore` files are never consulted, `.git/info/exclude` and the +/// user's global excludes are never read, and `core.excludesFile` does not exist here. That is the +/// ruling's own wording — "nested `.gitignore` files are ordinary strays the heal never consults" +/// — and it is what makes the noise definition something the user can see in one place. +/// - **Case-sensitive matching**, always. Git's is `core.ignorecase`'s to decide, which is a +/// repository setting on a file this app reads on boards that have no repository. The board's own +/// name comparisons go the other way (`IntegrityRules.reservedCardChildNames` is lowercased +/// "because the filesystem this runs on usually is"), but a *pattern* is the user's text and +/// folding it would silently widen what they wrote. +/// - **No POSIX bracket expressions** (`[[:digit:]]`): a `[` that does not close is a literal `[`, +/// and `[:digit:]` reads as the ordinary class it looks like. Nothing realistic in a board's noise +/// file uses them, and inventing a second character-class grammar to hold them would be the +/// over-engineering this type exists to avoid. +public struct GitignoreRules: Sendable, Equatable { + + /// The file's patterns, **in file order** — which is the whole of last-match-wins. + private let patterns: [Pattern] + + /// Parses a whole `.gitignore` body. + /// + /// **An empty file parses to no patterns and ignores nothing** — the escape hatch 06 names by + /// hand ("the escape hatch for wanting no exclusions is an *empty* file, which the app honors and + /// never rewrites"), and the same answer a board carrying no `.gitignore` at all gets. The two + /// are deliberately indistinguishable to every consumer: one of them is a statement and the other + /// is a silence, but neither excludes anything. + /// + /// Lines are split on **any** newline (`Character.isNewline`), which is git's own reading of a + /// CRLF file — `dir.c` terminates each pattern before the `\r` — arrived at from the other + /// direction: Swift treats `\r\n` as a single grapheme, so splitting on newline-ness drops the + /// carriage return by construction rather than by trimming it afterwards. A board hand-edited on + /// Windows must not end up carrying patterns nobody can match. A leading UTF-8 BOM is skipped for + /// the reason git skips one: it is a byte-order mark, not the first character of a pattern. + public init(parsing text: String) { + var body = Substring(text) + if body.hasPrefix("\u{FEFF}") { body = body.dropFirst() } + patterns = body.split(whereSeparator: \.isNewline).compactMap(Pattern.init(line:)) + } + + /// Whether the file said anything at all. `true` for a board with no `.gitignore` and for one + /// whose `.gitignore` is empty or all comments — the honored-empty state. + public var isEmpty: Bool { patterns.isEmpty } + + /// Whether `relativePath` — **board-relative**, `/`-separated, no leading slash (`//notes.txt`) + /// — is ignored by this file. + /// + /// `isDirectory` decides the directory-only patterns (`build/`) and nothing else. It describes + /// what is on disk at that path, which is the caller's to know: the loose-file gate always passes + /// `false`, because the carve-out is exactly *files*. + /// + /// **Ancestors are consulted first.** A path inside an excluded directory is excluded, and no + /// negation reaches it — git never descends into an ignored directory, so it never sees the rule + /// that would have re-included the file. Everything below `build/` is ignored even where the file + /// goes on to say `!build/keep.txt`. + public func isIgnored(relativePath: String, isDirectory: Bool = false) -> Bool { + guard !patterns.isEmpty else { return false } + let segments = relativePath.split(separator: "/").map(String.init) + guard !segments.isEmpty else { return false } + + for depth in 1 ..< segments.count where verdict(for: Array(segments.prefix(depth)), isDirectory: true) == true { + return true + } + return verdict(for: segments, isDirectory: isDirectory) == true + } + + /// The last-match-wins verdict for one path: `true` ignored, `false` explicitly re-included, + /// `nil` matched by nothing. + /// + /// `nil` and `false` behave identically at every call site today; they are kept apart because the + /// file's grammar keeps them apart — "this was never mentioned" and "this was mentioned and then + /// taken back" are different statements, and a future consumer (a listing that shows *why*) would + /// need the difference the moment it existed. + private func verdict(for segments: [String], isDirectory: Bool) -> Bool? { + var answer: Bool? + for pattern in patterns { + guard !pattern.directoryOnly || isDirectory else { continue } + guard pattern.matches(segments) else { continue } + answer = !pattern.isNegated + } + return answer + } +} + +// MARK: - One pattern + +extension GitignoreRules { + + /// One line of the file, compiled: what it matches, whether it re-includes, and whether it only + /// speaks about directories. + /// + /// **Anchoring is baked into `segments`** rather than carried as a flag: an unanchored pattern is + /// exactly its anchored self with a leading `**`, which is what `gitignore(5)` says in prose + /// ("the pattern may also match at any level below") and what removes a branch from the matcher. + fileprivate struct Pattern: Sendable, Equatable { + let segments: [Segment] + let isNegated: Bool + let directoryOnly: Bool + + /// `nil` for a line that is not a pattern at all — blank, or a comment. + init?(line: Substring) { + var text = Self.trimmingTrailingSpaces(line) + guard !text.isEmpty, text.first != "#" else { return nil } + + if text.first == "!" { + isNegated = true + text = text.dropFirst() + } else { + isNegated = false + } + + // The trailing separator is the directory-only marker, and it is *not* an interior + // separator for the anchoring question below: `foo/` matches a directory named `foo` at + // any depth, while `a/b/` is anchored. + if text.hasSuffix("/") { + directoryOnly = true + text = text.dropLast() + } else { + directoryOnly = false + } + guard !text.isEmpty else { return nil } + + let anchored = text.contains("/") + let parsed = text + .split(separator: "/", omittingEmptySubsequences: true) + .map(Segment.init(text:)) + guard !parsed.isEmpty else { return nil } + segments = anchored ? parsed : [.globstar] + parsed + } + + /// Whether this pattern matches the whole of `path`. + func matches(_ path: [String]) -> Bool { + Self.match(segments[...], path[...]) + } + + /// The segment walk, with `**`'s zero-or-more the only place it backtracks. + private static func match(_ pattern: ArraySlice, _ path: ArraySlice) -> Bool { + guard let head = pattern.first else { return path.isEmpty } + let tail = pattern.dropFirst() + + if case .globstar = head { + // **A trailing `**` matches everything *inside*** (`gitignore(5)`), so it needs a + // segment to consume: `a/**` matches `a/b`, never the bare `a`. Where `a` itself is + // ignored, some other pattern said so. + guard !tail.isEmpty else { return !path.isEmpty } + var remaining = path + while true { + if match(tail, remaining) { return true } + guard !remaining.isEmpty else { return false } + remaining = remaining.dropFirst() + } + } + + guard case let .literalOrWildcards(tokens) = head, + let name = path.first, + Segment.match(tokens[...], Array(name)[...]) + else { + return false + } + return match(tail, path.dropFirst()) + } + + /// Drops the trailing spaces git drops — every one that is not `\`-escaped. + /// + /// Only spaces, and only trailing: git trims exactly this (`trim_trailing_spaces`), so a + /// pattern ending in a tab keeps it, and a filename that really does end in a space stays + /// reachable by writing `foo\ `. + private static func trimmingTrailingSpaces(_ line: Substring) -> Substring { + var end = line.endIndex + while end > line.startIndex { + let previous = line.index(before: end) + guard line[previous] == " " else { break } + // Escaped when preceded by an odd number of backslashes. + var backslashes = 0 + var scan = previous + while scan > line.startIndex { + scan = line.index(before: scan) + guard line[scan] == "\\" else { break } + backslashes += 1 + } + guard backslashes.isMultiple(of: 2) else { break } + end = previous + } + return line[line.startIndex ..< end] + } + } + + /// One `/`-separated piece of a pattern. + fileprivate enum Segment: Sendable, Equatable { + /// Exactly `**` — zero or more path segments. "Other consecutive asterisks are considered + /// regular asterisks" (`gitignore(5)`), which is why this case is reserved for the whole + /// segment and never for a `**` sitting inside one. + case globstar + /// Everything else, tokenized once at parse time. + case literalOrWildcards([Token]) + + init(text: Substring) { + self = text == "**" ? .globstar : .literalOrWildcards(Token.tokenize(text)) + } + + /// Matches one path segment against one pattern segment — `*`'s backtracking, iteratively, + /// because a pattern is small and a recursion per `*` is not worth the stack. + static func match(_ tokens: ArraySlice, _ name: ArraySlice) -> Bool { + var t = tokens.startIndex + var n = name.startIndex + // Where to resume from if a `*` guessed short: the star itself and the character it was + // last asked to swallow up to. + var starToken: Int? + var starName = name.startIndex + + while n < name.endIndex { + if t < tokens.endIndex { + switch tokens[t] { + case .anyRun: + starToken = t + starName = n + t += 1 + continue + case .anyCharacter: + t += 1 + n += 1 + continue + case let .literal(character) where character == name[n]: + t += 1 + n += 1 + continue + case let .characterClass(group) where group.matches(name[n]): + t += 1 + n += 1 + continue + case .literal, .characterClass: + break + } + } + guard let star = starToken else { return false } + starName += 1 + n = starName + t = star + 1 + } + + // Trailing `*`s can still match nothing at all. + while t < tokens.endIndex, tokens[t] == .anyRun { t += 1 } + return t == tokens.endIndex + } + } + + /// One element of a pattern segment. + fileprivate enum Token: Sendable, Equatable { + case literal(Character) + /// `?` + case anyCharacter + /// `*` — any run, `/` excluded by construction (a token never sees a separator). + case anyRun + /// `[…]` + case characterClass(CharacterClass) + + /// Compiles one segment's characters. A `\` escapes whatever follows it; a trailing `\` is a + /// literal backslash (there is nothing left for it to escape); an unterminated `[` is a + /// literal `[`, which is git's own reading and the only one that cannot lose a character. + static func tokenize(_ text: Substring) -> [Token] { + var tokens: [Token] = [] + var index = text.startIndex + while index < text.endIndex { + let character = text[index] + switch character { + case "\\": + let next = text.index(after: index) + guard next < text.endIndex else { + tokens.append(.literal("\\")) + index = next + continue + } + tokens.append(.literal(text[next])) + index = text.index(after: next) + case "?": + tokens.append(.anyCharacter) + index = text.index(after: index) + case "*": + // Consecutive asterisks inside a segment are one ordinary `*`. + if tokens.last != .anyRun { tokens.append(.anyRun) } + index = text.index(after: index) + case "[": + if let (group, end) = CharacterClass.parse(text, from: index) { + tokens.append(.characterClass(group)) + index = end + } else { + tokens.append(.literal("[")) + index = text.index(after: index) + } + default: + tokens.append(.literal(character)) + index = text.index(after: index) + } + } + return tokens + } + } + + /// A `[…]` group: members, ranges, and the leading `!`/`^` negation. + fileprivate struct CharacterClass: Sendable, Equatable { + enum Member: Sendable, Equatable { + case single(Character) + case range(ClosedRange) + } + + let isNegated: Bool + let members: [Member] + + func matches(_ character: Character) -> Bool { + let hit = members.contains { member in + switch member { + case let .single(value): value == character + case let .range(range): range.contains(character) + } + } + return hit != isNegated + } + + /// Parses from the `[` at `start`, answering the group and the index just past its `]`, or + /// `nil` when the group never closes. + /// + /// A `]` **immediately after** the opening bracket (or its negation mark) is a literal member + /// rather than the terminator, which is the POSIX rule git inherits — `[]]` matches a bracket. + static func parse(_ text: Substring, from start: Substring.Index) -> (CharacterClass, Substring.Index)? { + var index = text.index(after: start) + var negated = false + if index < text.endIndex, text[index] == "!" || text[index] == "^" { + negated = true + index = text.index(after: index) + } + + var members: [Member] = [] + var first = true + while index < text.endIndex { + let character = text[index] + if character == "]", !first { + return (CharacterClass(isNegated: negated, members: members), text.index(after: index)) + } + first = false + + var value = character + if character == "\\" { + let next = text.index(after: index) + guard next < text.endIndex else { break } + value = text[next] + index = next + } + + // A `-` between two members is a range; one at either end of the group is a literal. + let afterValue = text.index(after: index) + if afterValue < text.endIndex, text[afterValue] == "-" { + let upperIndex = text.index(after: afterValue) + if upperIndex < text.endIndex, text[upperIndex] != "]" { + var upper = text[upperIndex] + var end = upperIndex + if upper == "\\" { + let escaped = text.index(after: upperIndex) + guard escaped < text.endIndex else { break } + upper = text[escaped] + end = escaped + } + // An inverted range (`z-a`) is nonsense; git's matcher never matches one, and + // `ClosedRange` would trap on it. + if value <= upper { members.append(.range(value ... upper)) } + index = text.index(after: end) + continue + } + } + + members.append(.single(value)) + index = text.index(after: index) + } + return nil + } + } +} diff --git a/Kanban/Storage/IntegrityRules.swift b/Kanban/Storage/IntegrityRules.swift index c6dc483..48d04e0 100644 --- a/Kanban/Storage/IntegrityRules.swift +++ b/Kanban/Storage/IntegrityRules.swift @@ -116,6 +116,13 @@ public enum IntegrityRules: Sendable { /// The file every level's content lives in. public static let indexFileName = "index.md" + /// **The board's noise definition** (01-storage-format.md § Fractal layout ▸ Rules, ruled + /// 2026-07-31; 06-history-undo.md ▸ Repository hygiene) — a board-root **file** name, claimed + /// because the app seeds it on every board and then reads it on every walk: it is the one list of + /// files the loose-file relocation heal leaves alone, and on a Pro board the same file is what + /// keeps that noise out of history. Seeded once, never edited afterwards. + public static let gitignoreFileName = ".gitignore" + /// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved /// child, never a level and never identity; the identities are the UUID folders inside it. public static let commentsFolderName = "comments" @@ -180,11 +187,24 @@ public enum IntegrityRules: Sendable { /// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled /// 2026-07-29 — "Lanework owns the board"), or left exactly where it is. /// - /// `false` for the two names that are *destinations* or not the app's to police: + /// `false` for the two names that are *destinations* or that the app can do without: /// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a /// destination by a second displacement would cascade renames (the settled skip stands — /// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit - /// (06-history-undo.md ▸ Repository hygiene), and nothing in the app reads it. + /// (06-history-undo.md ▸ Repository hygiene). + /// + /// **`.gitignore`'s `false` survived the 2026-07-31 re-ruling deliberately**, and the reason + /// changed underneath it. It used to be "nothing in the app reads it", which stopped being + /// true the day the file became the loose-file heal's noise gate (01-storage-format.md + /// § Fractal layout ▸ Rules) — by the level-uniform timing principle (a name "joins the + /// scheduled class the day the name becomes load-bearing", which is how `comments` graduated) + /// a folder squatting it would now be a candidate. It stays undisplaced because **the app + /// degrades to correctness without the file**: an unreadable noise definition means no + /// exclusions, which is exactly the behaviour boards had before the ruling and exactly what an + /// empty file — the ruling's own escape hatch — asks for. Nothing breaks while the name is + /// held, so nothing is displaced; that is the "proactive only when the defect is load-bearing + /// *now*" half of § Validation and healing. Neither 01 nor 06 asks for a displacement here, + /// and inventing one would move a node of the user's to buy a courtesy file. public let displacesSquatters: Bool } @@ -199,7 +219,7 @@ public enum IntegrityRules: Sendable { ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true), ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true), ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false), - ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false), + ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false), ] /// **The card-level claimed names** — the same table one level down (01-storage-format.md § Fractal @@ -451,9 +471,15 @@ public enum IntegrityRules: Sendable { // MARK: - Per-field validation (the rulebook) - /// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md - /// § Malformed input). Required at every level. - public static func validatedSchema( + /// `schema` **at the board root**, validated: present, well-formed, not newer than this app + /// (01-storage-format.md § Malformed input). + /// + /// **The root keeps its requirement** (re-ruled 2026-07-31): the root's stamp is the board's + /// identity claim — the this-really-is-a-board gate — so a root without one is the loud + /// rejection it always was, and the decision surface's minted repair (`stamp schema: 1`) is + /// what fixes it. Below the root the key is optional and defaults instead + /// (`resolvedSchema(in:path:supportedSchema:)`). + public static func validatedRootSchema( in document: FrontmatterDocument, path: String, supportedSchema: Int @@ -464,51 +490,111 @@ public enum IntegrityRules: Sendable { case let .malformed(raw): throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw)) case let .valid(value): - guard value <= supportedSchema else { - throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value)) - } + try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema) return value } } - /// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the - /// board itself — which is the whole of the per-kind difference in the tables today. - public static func validatedOrder( - in document: FrontmatterDocument, - path: String - ) throws(BoardLoadError) -> Double { - switch document.order { - case .missing: - throw BoardLoadError(path: path, reason: .missingOrder) - case let .malformed(raw): - throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw)) - case let .valid(value): - return value - } - } - - /// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather - /// than as two hand-written call sites in the loader's walk. + /// `schema` **below the board root**, read: a missing (or explicitly null) key reads as **1** + /// (01-storage-format.md § Frontmatter, re-ruled 2026-07-31 — "below the root a missing `schema` + /// reads as 1 — coerce-tier, logged, bytes preserved — because the walk validating the file + /// against schema 1 is exactly what makes the reading reliable"). /// - /// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema: - /// "**No `title`, no `order`**" — "Ordering is chronology, not ranks", because a conversation's - /// semantics *are* chronology and tracker-synced comments carry independent clocks where minted - /// ranks would interleave arbitrarily). - public static func requiresOrder(_ kind: ObjectKind) -> Bool { - switch kind { - case .board, .comment: false - case .lane, .card: true + /// Two things stay fail-fast here, and both are the same rule they always were: + /// + /// - **A schema newer than the app**, at any level: there is no honest reading of a file written + /// by a Lanework that knows keys this one does not, and defaulting would be guessing at content + /// rather than recovering it. + /// - **A malformed `schema`** — `schema: one`, `schema: [1]`. The ruling made the *absent* key + /// optional, not the unreadable one: absence is what a hand-filed item legitimately looks like, + /// while a value that is there and unreadable is a claim the app cannot check, and reading it + /// as 1 would be inventing agreement. + /// + /// - Returns: the reading, plus the coerce-tier record when one was made — the field, and the + /// text as written (`nil` when the key was present and valid, which is every file this app + /// ever wrote). The *path* is the loader's to attach (`CoercedFrontmatter`). + public static func resolvedSchema( + in document: FrontmatterDocument, + path: String, + supportedSchema: Int + ) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) { + switch document.schema { + case .missing: + return (defaultSchema, coercedRecord(for: FrontmatterKeys.schema, in: document, raw: nil)) + case let .malformed(raw): + throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw)) + case let .valid(value): + try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema) + return (value, nil) } } + /// The reading a `schema`-less file below the root gets — the schema this document *is* being + /// validated against, which is the argument for the default rather than a coincidence. + public static let defaultSchema = 1 + + private static func checkNotNewerThanApp( + _ value: Int, + path: String, + supportedSchema: Int + ) throws(BoardLoadError) { + guard value <= supportedSchema else { + throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value)) + } + } + + /// `order` **below the board root**, read: `nil` where the file carries no rank this app can + /// use — which the caller reads as **append-at-end** (`Ranks.resolvedOrders(of:stored:name:)`). + /// + /// **Four shapes, one reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31 — + /// "Missing or unusable `order` reads as append-at-end"): a missing key, an explicitly null one + /// (`order:` bare, `order: null` — the null-as-missing rule), a non-numeric value, and a + /// non-finite one (`.nan`, `.inf`, which has no place in the total order the tie-break and + /// midpoint math assume). None of them is a load failure any more; all of them are coerce-tier — + /// bytes preserved verbatim, the reading logged, the rank materializing on the file's first + /// touch (`healOnTouch`). + /// + /// It never throws, and has no `path`: with nothing left to refuse, there is nothing to name. + /// + /// **The board root never asks.** A board carries no rank — it is not among siblings — so the + /// key is meaningless there and this is simply not called on it, exactly as it never was. + public static func resolvedOrder( + in document: FrontmatterDocument + ) -> (order: Double?, coerced: CoercedField?) { + switch document.order { + case .valid(let value): + return (value, nil) + case .malformed(let raw): + return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: raw)) + case .missing: + return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: nil)) + } + } + + /// One coerce-tier record: the field and **the value exactly as written**, which for an absent + /// key is the empty string — there is no text to record, and inventing a rendering of one would + /// defeat the record's whole purpose (a shape observed in the wild, promotable later to a + /// heuristic heal). An explicitly-null key answers with its own (empty) source span, so + /// "started the key and never gave it a value" is preserved as exactly that. + private static func coercedRecord( + for key: String, + in document: FrontmatterDocument, + raw: String? + ) -> CoercedField { + CoercedField(key: key, raw: raw ?? document.rawValue(for: key) ?? "") + } + /// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that /// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per /// kind — board, lane, card, the enhanced schema's comment when it lands"). /// /// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own - /// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing - /// further is checked, because nothing else *is*: `title` is optional, unknown keys are the - /// point of the outlet the card validator serves, and the body is free text. + /// functions: decode + parse, then `schema` — at the root's strictness for a board, at the + /// below-the-root reading for everything else. Nothing further is checked, because nothing else + /// *is*: `title` is optional, `order` is optional below the root since 2026-07-31 (a rankless + /// card applied through the raw-source outlet lands at its lane's bottom and gains a rank on its + /// next touch, exactly as a hand-filed one does), unknown keys are the point of the outlet the + /// card validator serves, and the body is free text. /// /// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span /// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping @@ -520,9 +606,11 @@ public enum IntegrityRules: Sendable { supportedSchema: Int ) throws(BoardLoadError) -> FrontmatterDocument { let document = try BoardLoader.parseDocument(data, path: path) - _ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema) - if requiresOrder(kind) { - _ = try validatedOrder(in: document, path: path) + switch kind { + case .board: + _ = try validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema) + case .lane, .card, .comment: + _ = try resolvedSchema(in: document, path: path, supportedSchema: supportedSchema) } return document } @@ -551,6 +639,23 @@ public enum IntegrityRules: Sendable { /// add a key that is redundant with position everywhere else. case kindBackfilled(ObjectKind) + /// `order` was missing or unusable and has been stamped with the rank the board was + /// **already rendering it at** — the append-at-end reading, materialized + /// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "The rank materializes on touch: + /// the first Writer rewrite of the file stamps a real rank"). + /// + /// Nothing moves on screen when it lands, and that is the design rather than a happy + /// accident: the stamped value is `Ranks.resolvedOrders`' own answer for this file, so the + /// heal writes down what the reading already said. It is on-touch for the reason the whole + /// class is — the defect is latent, the file is being rewritten anyway, and a sweep that + /// rewrote a board to add ranks nobody asked for would be churn. + /// + /// **Unlike `kind`, an unusable *present* value is replaced.** `order: banana` and + /// `order: .nan` are read as order-less (the value-names-the-kind posture has no twin here: + /// a rank must be a number for the midpoint math to mean anything), so the stamp is the same + /// heal in both cases and the unreadable text does not survive the rewrite. + case rankStamped(Double) + /// A key written twice collapsed to its winning (last) occurrence — the span editor's /// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an /// on-touch heal and was only ever documented as an editing detail: last-wins is the read @@ -569,28 +674,43 @@ public enum IntegrityRules: Sendable { /// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is /// rewriting"). /// - /// Only the `kind` backfill is applied *here*; the other two members of the class are applied by - /// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal` - /// rather than re-implemented. That is the honest shape of "the same class, named, no behavior - /// change". + /// Two of the four members are applied *here* — the `kind` backfill and the rank stamp; the + /// other two are applied by `FrontmatterDocument`'s own editor on every key it writes, and are + /// named in `OnTouchHeal` rather than re-implemented. That is the honest shape of "the same + /// class, named, no behavior change". /// - /// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never - /// stripped — the value names the kind and consumers trust it outright. An explicit `kind:` - /// with nothing after it reads as missing, like every other null (the null-as-missing rule), - /// and so backfills. + /// - **`kind`: missing only.** A present `kind` is never rewritten, never corroborated, and + /// never stripped — the value names the kind and consumers trust it outright. An explicit + /// `kind:` with nothing after it reads as missing, like every other null (the null-as-missing + /// rule), and so backfills. /// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and /// a guessed kind written to disk would be worse than an absent one: the trash discriminator /// trusts what it finds. - /// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the - /// trash's shape fallback) pays for it only on a file that actually needs the backfill. + /// - **`order`: missing *or unusable*.** The reading is stated over usability + /// (`resolvedOrder(in:)`), so the stamp is too. + /// - **`rank == nil` stamps nothing** — the board root (which has no siblings and no rank), a + /// comment (which never gains one — "Ordering is chronology, not ranks"), and any folder whose + /// position has no answer. The same posture as `kind`'s: a rank invented for something that is + /// not a lane or a card would be a fact nobody asked for. + /// - Parameters kind, rank: `@autoclosure` so a caller whose answer costs a directory listing + /// (the trash's shape fallback; the rank's own sibling scan) pays for it only on a file that + /// actually needs the heal — which, on a board this app wrote, is never. @discardableResult public static func healOnTouch( _ document: inout FrontmatterDocument, - kind: @autoclosure () -> ObjectKind? + kind: @autoclosure () -> ObjectKind?, + rank: @autoclosure () -> Double? = nil ) -> [OnTouchHeal] { - guard document.kind.isMissing, let kind = kind() else { return [] } - document.set(FrontmatterKeys.kind, to: .string(kind.rawValue)) - return [.kindBackfilled(kind)] + var applied: [OnTouchHeal] = [] + if document.kind.isMissing, let kind = kind() { + document.set(FrontmatterKeys.kind, to: .string(kind.rawValue)) + applied.append(.kindBackfilled(kind)) + } + if document.order.value == nil, let rank = rank() { + document.set(FrontmatterKeys.order, to: .double(rank)) + applied.append(.rankStamped(rank)) + } + return applied } // MARK: - The typed defect stream @@ -640,6 +760,12 @@ public enum IntegrityRules: Sendable { /// It is a class here because the engine treats it exactly like the others — same gates, /// same memo, same clear-on-success. /// + /// `missingGitignore` is the third, and the guide's twin in every respect: the board's noise + /// definition is one board-root file's presence, read at the moment of healing + /// (`BoardWriter.seedGitignoreIfAbsent`) rather than reported by a tree walk — "a board + /// missing the file gains it by scheduled heal at open (the guide-refresh cadence)" + /// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31). + /// /// `commentTrashResidue` is the second such class, for the same reason one level down: the /// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the /// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the @@ -652,6 +778,7 @@ public enum IntegrityRules: Sendable { case claimedNameSquatted case duplicateIdentity case staleAgentGuide + case missingGitignore case commentTrashResidue } diff --git a/Kanban/Storage/Ranks.swift b/Kanban/Storage/Ranks.swift index 44ac52e..53984ab 100644 --- a/Kanban/Storage/Ranks.swift +++ b/Kanban/Storage/Ranks.swift @@ -119,11 +119,63 @@ enum Ranks: Sendable { return (1...count).map { Double($0) * gap } } + // MARK: - The append-at-end reading + + /// **Every sibling's rank, with the order-less ones read as append-at-end** — the whole of the + /// optional-`order` ruling's read side (01-storage-format.md § Ordering, re-ruled 2026-07-31: + /// "Missing or unusable `order` reads as append-at-end … order-less items sort after every + /// ordered sibling, among themselves by the folder-name tie-break — deterministic with zero + /// sibling reads, which is what makes the minimum agent card legal"). + /// + /// - Parameter stored: the `order` the file actually carries, or `nil` where it carries none the + /// loader can use — missing, explicitly null, non-numeric, non-finite. The four shapes are one + /// answer here on purpose: the reading is stated over *usability*, not over which way a value + /// failed to be usable (`IntegrityRules.resolvedOrder` is where the four are told apart, for + /// the coerce-tier record). + /// - Parameter name: the folder name — the tie-break the design states this ordering in. + /// - Returns: one rank per sibling, **positionally aligned with `siblings`** (never reordered: + /// callers sort afterwards, through `sortedForDisplay`, exactly as they always did). + /// + /// **The materialized ranks are `append`'s own arithmetic**, and that is the load-bearing + /// property rather than a convenience: the k-th order-less sibling reads as `max + 1024·k` over + /// the ranks actually written down, which is exactly where `append(toVisible:)` would have put it + /// had it been filed by the app. So the reading a board *renders* is a rank ladder the Writer can + /// stamp verbatim — which is what the on-touch and inline stamps do + /// (`BoardWriter.stampAppendAtEndOrders`), and why stamping one changes nothing on screen. + /// + /// An empty ranked set bases at `0`, so a container of nothing but order-less items reads + /// `1024, 2048, …` — the same board convention `append` gives an empty container's first child. + /// + /// **Accepted cost, stated by the ruling**: two order-less siblings sort by folder name rather + /// than by intent until something touches them. + static func resolvedOrders( + of siblings: [T], + stored: (T) -> Double?, + name: (T) -> String + ) -> [Double] { + let storedOrders = siblings.map(stored) + var resolved = storedOrders.map { $0 ?? 0 } + let orderless = storedOrders.indices.filter { storedOrders[$0] == nil } + guard !orderless.isEmpty else { return resolved } + + let base = storedOrders.compactMap { $0 }.max() ?? 0 + let queue = orderless.sorted { name(siblings[$0]) < name(siblings[$1]) } + for (step, index) in queue.enumerated() { + resolved[index] = base + gap * Double(step + 1) + } + return resolved + } + // MARK: - Display order /// Ascending display order: primary key `order`, ties broken by folder /// name (lexicographic) for deterministic rendering. Shared by the /// loader and the writer so both apply the same tie-break rule. + /// + /// An order-less sibling reaches here already carrying its append-at-end + /// reading (`resolvedOrders(of:stored:name:)`), so this comparator needs no + /// case for one: "after every ordered sibling, then by folder name" *is* + /// this rule applied to the materialized ranks. static func isOrderedForDisplay( _ lhs: T, before rhs: T, order: (T) -> Double, name: (T) -> String diff --git a/Kanban/UI/Board/BoardGitControls.swift b/Kanban/UI/Board/BoardGitControls.swift index f3c29ae..4d8e915 100644 --- a/Kanban/UI/Board/BoardGitControls.swift +++ b/Kanban/UI/Board/BoardGitControls.swift @@ -235,8 +235,15 @@ struct BoardGitControls: View { // MARK: - Commit identity /// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction -/// with external writers: "The board popover's git section exposes name/email fields that write that -/// repo-local config — the setting *is* the file, portable to any git client, per-board by nature"). +/// with external writers: "The board settings sheet's identity section … exposes name/email fields +/// that write that repo-local config — the setting *is* the file, portable to any git client, +/// per-board by nature"). +/// +/// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved +/// every setup-shaped control — add-git, branch creation, these fields, remote and credentials — out +/// of the popover and into a board settings sheet (03-board-ui.md ▸ Board settings sheet), leaving the +/// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves +/// them, along with the visibility-scoped poll below, which 06 says "rides with the fields". /// /// ### The placeholder is the whole of the identity rule made visible /// @@ -270,8 +277,11 @@ private struct BoardGitIdentityFields: View { case email } - /// How often an open popover re-reads the config file. Slow enough to be free, fast enough that a - /// terminal `git config user.email …` shows up while the user is still looking at the popover. + /// **The fields re-read the config at 2 s while the surface is visible** (06 ▸ Interaction with + /// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event + /// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and + /// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused + /// field keeps its keystrokes; dismissing the sheet stops the poll)." private static let pollInterval: Duration = .seconds(2) var body: some View { diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index 12dc5d9..e37cf54 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -424,6 +424,13 @@ private struct BoardGitAddAction: View { .fixedSize(horizontal: false, vertical: true) } } + // **The form add-git answers at** (06 ▸ Interaction with external writers, ruled 2026-07-31 — + // "Form-anchored operations answer at the form first"; its container moved to the board + // settings sheet in the 2026-07-31 split, and these two lines are what that sheet re-points). + // Appearing claims the inline surface; disappearing gives it up, which both dismisses the + // stale error and sends any answer still in flight to the banner instead of to nobody. + .onAppear { git.noteFormVisible(true) } + .onDisappear { git.noteFormVisible(false) } } } diff --git a/Kanban/UI/Card/CardBodyEditSession.swift b/Kanban/UI/Card/CardBodyEditSession.swift index 86508db..74a10b3 100644 --- a/Kanban/UI/Card/CardBodyEditSession.swift +++ b/Kanban/UI/Card/CardBodyEditSession.swift @@ -43,12 +43,18 @@ import Observation /// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other /// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save. /// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window -/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because -/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its -/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save -/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). In the free tier there is no committer, so -/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart -/// later. +/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because it +/// is where this session's **one undo step** is registered: every debounced tick inside a session +/// rides its own `performWrite` bracket, and 13-native-undo.md's rule is one step per session, never +/// per save tick (`registerUndo`). +/// +/// **The commit boundary is no longer here** (06-history-undo.md ▸ Rules ▸ Auto-commit, widened +/// 2026-07-31 — "Board history sees card-window sessions, not gestures"): the committer's +/// stage-around used to open and close with this flip, and it now opens with the *window* and +/// releases when the window's session ends, so a comment posted with the body in Preview is inside +/// the same one commit as the body. What this type still owes that model is `isEditing`, read as +/// unsaved-content state rather than announced (`CardWindowHost`); the announcement seam it used to +/// wire went with the widening. @MainActor @Observable public final class CardBodyEditSession { @@ -110,23 +116,6 @@ public final class CardBodyEditSession { @ObservationIgnored public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)? - /// **The session boundary, announced** — called with `true` when an Edit session opens and - /// `false` when it ends, and with nothing in between. - /// - /// **No longer the committer's stage-around boundary** (06-history-undo.md ▸ Rules ▸ Auto-commit, - /// widened 2026-07-31 — "Board history sees card-window sessions, not gestures"): the exclusion - /// used to open and close with this flip, and it now opens with the *window* and releases when its - /// session ends, so a comment posted with the body in Preview is inside the same one commit as the - /// body. `CardWindowHost` therefore no longer wires this to anything, and the Edit→Preview flip is - /// a save point rather than a commit point. - /// - /// The seam stays, unwired, because it is the only announcement of the boundary this type makes - /// and the ordering it carries — that the flush precedes the announcement — is a property worth - /// keeping proved (`AutoCommitTests`). A closure for `save`'s reason exactly: this type is a - /// buffer and a clock, and it stays testable by having no idea what a repository is. - @ObservationIgnored - public var editSessionDidChange: ((_ isEditing: Bool) -> Void)? - /// What disk said before this session's **first** landed save — the step's before-value, held /// from the first write until the session ends. /// @@ -194,16 +183,16 @@ public final class CardBodyEditSession { /// because its card's body was empty (`CardBodyMode.opening(body:)`). /// /// Idempotent, because the mode can be re-asserted by a menu validation pass or a re-published - /// focus value, and a second announcement would register a session that is already registered. + /// focus value, and `isEditing` is observed: assigning `true` over `true` would still notify, and + /// the notification is a view rebuild for a session that never changed (`adopt(diskBody:)`'s + /// equal-assignment note, one property along). public func beginEditSession() { guard !isEditing else { return } isEditing = true - editSessionDidChange?(true) } /// The end of one Edit session — the flip back to Preview, raw-source entry, or the window - /// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's - /// doc comment). + /// closing. Flushes, then registers the session's one undo step (see the type's doc comment). @discardableResult public func endEditSession() -> CardBodyWriteOutcome { let outcome = flush() @@ -216,14 +205,9 @@ public final class CardBodyEditSession { registerUndo?(origin, disk) } sessionOriginBody = nil - // **Last**, after the flush and after the undo step: the committer's nudge must find the - // session's final bytes already on disk, or the commit it arms would carry the file as it - // stood one keystroke ago. Guarded on `isEditing` so a window closing from Preview — which - // calls this too, and should — announces nothing. - if isEditing { - isEditing = false - editSessionDidChange?(false) - } + // Guarded on `isEditing` for `beginEditSession`'s reason — a window closing from Preview + // calls this too, and should, but has no session flag to move. + if isEditing { isEditing = false } return outcome } @@ -243,10 +227,7 @@ public final class CardBodyEditSession { cancelPending() text = disk sessionOriginBody = nil - if isEditing { - isEditing = false - editSessionDidChange?(false) - } + if isEditing { isEditing = false } } /// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of diff --git a/Kanban/UI/Card/CardComments.swift b/Kanban/UI/Card/CardComments.swift index 4963f0a..7355123 100644 --- a/Kanban/UI/Card/CardComments.swift +++ b/Kanban/UI/Card/CardComments.swift @@ -105,6 +105,14 @@ public final class CardComments { /// The crash-residue sweep, run once when the window opens — /// `BoardStore.sweepCommentTrashResidue(inCard:)`. + /// + /// **It is a sweep of *residue*, not of the trash** (13-native-undo.md ▸ Interaction with the + /// trash, ruled 2026-07-31): content a live board step still restores from is that step's backing + /// and is left standing, so reopening a card can never destroy the last session's undo. **Nor the + /// bytes a collision left**, since the same day's skip-purge decoupling: a coarse step popped as + /// stale applied nothing and destroys nothing, and what it was holding stands until the board + /// session ends. The gate lives store-side, where the stack is; this seam is unchanged and still + /// called exactly once, by `open()`. @ObservationIgnored public var sweepTrashResidue: (() -> Void)? diff --git a/KanbanTests/AgentGuideTests.swift b/KanbanTests/AgentGuideTests.swift index 15a7221..0c6c895 100644 --- a/KanbanTests/AgentGuideTests.swift +++ b/KanbanTests/AgentGuideTests.swift @@ -22,10 +22,18 @@ import Testing // MARK: - Shared fixtures /// A one-lane board — enough tree that a reload has something to walk. +/// +/// It carries the **seeded `.gitignore`**, which is what any board the app has opened once looks +/// like (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31). Without it the store's own +/// seeding heal — which runs on every successful reload beside this file's guide refresh — would +/// write that file on the first reload and open a bracket of its own, and the bracket counts below +/// would stop being claims about the guide. (`LooseFileRelocationTests`' fixture carries the guide +/// for the mirror-image reason.) @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) + try fixture.file(IntegrityRules.gitignoreFileName, Data(BoardWriter.gitignoreSeed.utf8)) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) return fixture } @@ -721,6 +729,31 @@ struct AgentGuideContentTests { #expect(content.contains("quoted value left unclosed across a")) } + /// **v10: the zero-read minimum** (01-storage-format.md § Frontmatter and § Ordering, re-ruled + /// 2026-07-31). 08-agent-integration.md's masterplan requirement — "filing a card must need + /// nothing but the schema" — was untrue while a card needed a rank, because a rank needed a scan + /// of every sibling in the lane. The guide has to teach both halves: the short form is legal and + /// lands at the bottom, and *writing* `order` is still the only way to choose a position. + @Test("v10 teaches optional keys and the zero-read minimum") + func v10OptionalKeyVocabularyIsPresent() { + let content = AgentGuide.content + // The keys are optional below the root, and the root's `schema` is not. + #expect(content.contains("**required at the board's own `index.md`**")) + #expect(content.contains("optional below it — a lane or card without one is read as schema 1")) + #expect(content.contains("**optional, and the way to control position**")) + // The minimum card, and where it lands. + #expect(content.contains("**You can also file a card without reading the lane at all.**")) + #expect(content.contains("no `order`, no `schema`")) + #expect(content.contains("It lands at the bottom of the lane")) + #expect(content.contains("the app\nwrites a real `order` into it")) + // Reading order names the rule the minimum card depends on. + #expect(content.contains("An item with no\n `order` sorts after every item that has one")) + // Hard rules no longer calls either key required below the root. + #expect(content.contains("`schema` and `order` are optional and a\n missing one is read, never refused")) + #expect(!content.contains("Lanes and cards additionally require `order`")) + #expect(!content.contains("plus `order` on lanes and")) + } + /// The pathfinder's guide taught `media/` and tombstone deletes; both are retired /// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate /// mention of `deleted:` is the warning never to write it. diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift index 6811b64..1f43400 100644 --- a/KanbanTests/AppModelTests.swift +++ b/KanbanTests/AppModelTests.swift @@ -62,6 +62,49 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef return ref } +/// The same board with a **real repository** at its root, root commit and all — what a Pro session +/// detects as mode `git`, and therefore the only shape that composes a branch switcher to test the +/// settle step's seams through. +@MainActor +private func makeProGitBoard() throws -> WriterFixture { + let fixture = try makeMixedBoard() + guard case .success = GitRepository.create(at: fixture.root) else { + fixture.tearDown() + Issue.record("could not initialize a repository for the fixture board") + throw CocoaError(.fileWriteUnknown) + } + return fixture +} + +/// Opens a card window the way its host does — registered with the board's session — and leaves it +/// holding one fine step and an open Edit session, so it both *has* a stack to lose and answers the +/// save-or-discard step's `needsSettling` with `true`. +/// +/// The step is registered through `BoardStore.registerStep` rather than pushed onto the provider, so +/// it is routed by the same line production routes a card-window gesture with (`on:` → the window's +/// stack) and carries the raw write the close fold would look for. +@MainActor +@discardableResult +private func openCardWindow( + _ model: AppModel, + board: BoardWindowRef, + card id: String, + store: BoardStore +) -> CardWindowSession { + let window = CardWindowSession() + model.registerCardWindow(CardWindowRef(board: board, cardID: ItemID(rawValue: id)), session: window) + window.body.beginEditSession() + store.registerStep( + "Edit Card", + on: window.undo, + undoExpects: [.present(.card(ItemID(rawValue: id)), .body("after\n"))], + redoExpects: [.present(.card(ItemID(rawValue: id)), .body("before\n"))], + undo: { _ in }, + redo: { _ in } + ) + return window +} + // MARK: - Tests @MainActor @@ -276,6 +319,101 @@ struct AppModelTests { model.storeRegistry.release(try #require(model.session(for: ref)?.store)) } + // MARK: The branch switch's settle + + /// **"The settle also clears each open card window's fine undo stack"** (06-history-undo.md + /// ▸ Branch switching, ruled 2026-07-31): "pre-switch steps describe the branch being left — Save + /// All and Discard alike end with every window's stack empty … the windows stay open, following + /// their cards onto the new branch with fresh stacks." + /// + /// This is an `AppModel` test rather than a `GitBranchSwitcher` one because the clear is a fact + /// about the **composition**: the switcher's settle seam, the card-window registry and the stacks + /// themselves only meet in `wireBranchSwitching`, and a switcher wired by hand would be a test + /// asserting its own wiring (the `a381fac` lesson, applied one card later). + @Test( + "The branch switch's settle empties every open card window's fine stack", + arguments: [SessionSettleChoice.saveAll, .discard] + ) + func theSettleClearsEveryFineStack(answering choice: SessionSettleChoice) async throws { + let board = try makeProGitBoard() + defer { board.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: board.root) + let session = try #require(model.session(for: ref)) + let switcher = try #require(session.git?.switcher) + + let windows = [Ident.card1, Ident.card2].map { id in + openCardWindow(model, board: ref, card: id, store: session.store) + } + #expect(windows.allSatisfy { $0.undo.stack.canUndo }) + #expect(windows.allSatisfy { $0.undo.netEffect() != nil }, "a session with a net effect to fold") + + model.settleAsk = { _ in choice } + #expect(await switcher.settleSessions?() == .proceed) + + for window in windows { + #expect(!window.undo.stack.canUndo, "the stack describes the branch being left") + #expect(!window.undo.manager.canUndo, "and ⌘Z in that window answers with it") + } + // "The windows stay open, following their cards onto the new branch with fresh stacks." + #expect(model.session(for: ref)?.cardRefs.count == 2) + } + + /// **Closing the stack is not closing the window.** The coarse step a card window owes its board is + /// registered at *close*, folded from this stack (13-native-undo.md ▸ Rules ▸ "Window close + /// coarsens"); a settle clear registers nothing at all, which is exactly what + /// `registerCardSession` answering `false` — and the deferred purge staying the caller's — says. + @Test("A settle clear registers no coarse step — the fold that would have run finds nothing") + func theClearRegistersNoCoarseStep() async throws { + let board = try makeProGitBoard() + defer { board.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: board.root) + let session = try #require(model.session(for: ref)) + let switcher = try #require(session.git?.switcher) + let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store) + + model.settleAsk = { _ in .saveAll } + #expect(await switcher.settleSessions?() == .proceed) + + #expect(window.undo.netEffect() == nil, "nothing left to fold") + var purged = false + let registered = session.store.registerCardSession( + window.undo, + inCard: ItemID(rawValue: Ident.card1), + retiring: { purged = true } + ) + #expect(!registered, "a close arriving right after the switch registers nothing") + #expect(!purged, "and the deferred purge is still the caller's, not a step's") + } + + /// "**Cancel** keeps the current branch and the sessions" — and now their stacks with them. The + /// same `if` that withholds the staging release withholds this. + @Test("Cancel clears nothing") + func cancelKeepsTheFineStacks() async throws { + let board = try makeProGitBoard() + defer { board.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: board.root) + let session = try #require(model.session(for: ref)) + let switcher = try #require(session.git?.switcher) + let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store) + + model.settleAsk = { _ in .cancel } + #expect(await switcher.settleSessions?() == .cancelled) + #expect(window.undo.stack.canUndo) + #expect(window.undo.netEffect() != nil) + } + // MARK: Launch restoration /// App ▸ Settings…'s "Restore open boards at launch" (11-command-nexus.md) gates the flagged set diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index 21e9c25..70bdc19 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -414,6 +414,17 @@ struct AutoCommitAttributionTests { #expect(committer.commitCount == 2, "the heal's paths commit separately — the split's third class") #expect(isClean(at: fixture.root)) + + // **And it is authored by the third pinned synthetic** (06 ▸ Commit messages ▸ Healing + // mutations commit separately, ruled 2026-07-31): "a heal is a third origin — not the user's + // gesture, not a foreign writer — and the separation exists for audit, so the trail filters by + // author like every origin; the committer stays the user." + let log = try history(at: fixture.root) + let user = GitCommitOperation.userIdentity(at: fixture.root) + #expect(log[0].authorEmail == user.email, "the user's own write stays the user's") + #expect(log[1].authorName == CommitAttribution.integrityAuthorName) + #expect(log[1].authorEmail == CommitAttribution.integrityAuthorEmail) + #expect(log[1].committerName == user.name, "the committer is always the user") } @Test("The app's own delete is the user's, not an agent's") @@ -553,6 +564,70 @@ struct AutoCommitStageAroundTests { #expect(committer.commitCount == 0) #expect(committer.lastFailure == nil, "an empty window is a no-op, never a failure") } + + /// **A close flush queues behind an in-flight flush rather than skipping it** (06 ▸ Rules + /// ▸ Auto-commit: "nothing settled is ever left unsaved or uncommitted by closing"). + /// + /// The interleaving is the close sequence's own, forced rather than waited for. `endCardSession` + /// releases the stage-around **and arms a fresh debounce**, and `CloseFlushCoordinator` then + /// spends its card-drain deadline before reaching `committerFlush` — two intervals that are both + /// two seconds, so in practice the debounce fired into the drain's last moments about half the + /// time. What made that a defect rather than a coin toss is what the debounced flush had already + /// planned: a commit whose exclusion list still held the session's folder. Skipping behind it left + /// the session uncommitted *permanently* — teardown stops the committer, and there is no later + /// flush anywhere. + /// + /// So the flush in flight here is deliberately one that planned **with** the exclusion, and the + /// release happens while it is still running. Before the fix this test's `flushNow()` returned + /// having done nothing and the card's body stayed dirty forever. + @Test("A flush asked for while one is in flight waits for it, and commits what it was asked to") + func anExplicitFlushIsNeverDroppedBehindAnInFlightOne() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + // The one point inside a flush that is both off the main actor and injectable: composing. + // It holds the flush open long enough for the close to arrive underneath it. + committer.composer = SlowComposer(delay: 0.4) + + let token = UUID() + committer.beginCardSession(token) { fixture.url("\(Ident.lane1)/\(Ident.card1)") } + + // The session's uncommitted work, held by the stage-around… + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + plain(order: "1024", title: "First", body: "typed and never committed")) + // …and a board change beside it, so the debounced flush has something to compose slowly about + // rather than answering `nothingToCommit` before it ever reaches the composer. + try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing")) + + // Arm the debounce and let it fire: from here until the composer returns, a flush is in + // flight, and it planned its commit while the session folder was still excluded. + committer.noteReloadLanded(sawForeignChange: true) + try await waitUntil { committer.isCommitInFlight } + + // The close sequence, arriving underneath it: the session ends, its folder is released, and + // the coordinator asks for the flush that must not be lost. + committer.endCardSession(token) + await committer.flushNow() + + #expect(isClean(at: fixture.root), + "the close flush waited its turn and committed the session it was asked to") + #expect(GitRepository.trackedPaths(at: fixture.root) + .contains("\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)")) + } +} + +/// A composer that takes its time, so a test can hold a flush open and drive the close sequence into +/// the gap. Everything else about it is the real one — this suite asserts *when* a commit exists, and +/// a fake message would make the commits it reads back unrecognisable. +private struct SlowComposer: CommitMessageComposing { + let delay: TimeInterval + + func message(for request: CommitMessageRequest) -> String { + // Blocking, deliberately: this runs on the flush's own detached task, and what the test needs + // held open is that task rather than the actor the close sequence is running on. + Thread.sleep(forTimeInterval: delay) + return CommitMessageEngine.message(for: request) + } } // MARK: - Contention, holds, and failure @@ -823,29 +898,155 @@ struct AutoCommitCompositionTests { } } -// MARK: - The Edit-session boundary +// MARK: - The composition root +/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md ▸ Layering; +/// 12-editions.md ▸ The provider seam). +/// +/// Every suite above composes its own committer by hand, which is what makes them readable and is +/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed +/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one, +/// for the store-less callers), so every unit layer passed while every production commit misattributed +/// — the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in +/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model* +/// could have caught it or can keep it caught. +/// +/// So the assertions here are about the **composition** and never about the units: not "the ledger +/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion" +/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in +/// it. @MainActor -@Suite("Auto-commit ▸ the Edit-session boundary") +@Suite("Auto-commit ▸ the composition root") +struct AutoCommitCompositionRootTests { + + /// An `AppModel` whose app-side state lives in temp rather than in the app's real Application + /// Support home — `AppModelTests`' own fixture, for its reason. + private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("AutoCommitCompositionTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let model = AppModel( + registryStorageURL: folder.appendingPathComponent("board-registry.json"), + clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) + ) + model.currentTier = { .pro } + return (model, { try? FileManager.default.removeItem(at: folder) }) + } + + /// Opens a board the way `BoardWindowHost` does — record, acquire, flag, begin — so what is under + /// test is the real `beginSession` and not a hand-assembled session. + private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession { + let ref = BoardWindowRef(url: url) + let recordID = model.boardRegistry.recordOpen(of: url) + let store = try model.storeRegistry.acquire(url) + model.boardRegistry.setOpenNow(id: recordID) + model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) + return try #require(model.session(for: ref)) + } + + /// **(a) The committer is composed with the session store's own ledger** — the same instance the + /// store's writes drop receipts into (`BoardStore.echoes`). + /// + /// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated + /// write through the store, committed by the session's own committer, is authored by this + /// machine's user. Composed with any *other* ledger it would be authored `Lanework External` — + /// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`' + /// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every + /// commit did before `a381fac`. + @Test("beginSession composes the committer with the store's own EchoLedger") + func theCommitterIsComposedWithTheStoresLedger() async throws { + let (fixture, _, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + + let session = try openBoard(model, at: fixture.root) + let committer = try #require(session.git?.committer) + // Only the explicit flush commits, and it does not sit out a watcher that a temp directory may + // or may not deliver events for: this test is about *who* the commit is by. + committer.stop() + committer.debounceInterval = .seconds(30) + committer.coveringSnapshotDeadline = .milliseconds(50) + committer.coveringSnapshotPollInterval = .milliseconds(5) + + // An ordinary write through the store — the Writer boundary, receipt and all. Nothing here + // touches the ledger by hand, which is the whole point: the receipt has to travel from the + // store's own ledger to the committer's, and there is only one way for that to be true. + let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n") + #expect(outcome == .written) + await committer.flushNow() + + let head = try #require(try history(at: fixture.root).first) + #expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email) + #expect( + head.authorEmail != CommitAttribution.externalAuthorEmail, + "a committer composed over any other ledger would blame the outside world for this write" + ) + } + + /// **(b) The announcer outlet is bound** — the undo restore's bracket runs through the store's + /// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`. + /// + /// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a + /// repository-level test wants" — so an unwired seam is silent rather than broken, and every + /// repository-level suite in this file would keep passing over one. What a session owes it is the + /// store's bracket: the watcher suspension, the reload floor that locks the board if the closing + /// reload fails, and 10-accessibility.md's one sentence at completion. + @Test("beginSession binds the restore's bracket to the board's announcer outlet") + func theRestoreBracketReachesTheAnnouncer() async throws { + let (fixture, _, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + + let session = try openBoard(model, at: fixture.root) + session.git?.committer?.stop() + let store = session.store + let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider") + + var spoken: [String] = [] + store.announce = { if let phrase = $0 { spoken.append(phrase) } } + + let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside") + await bracket("Undid 'Add card'") { + try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored")) + } + // The bracket's closing reload — the one it armed, whichever way the operation went. + store.handleWatcherEvent(.treeChanged(.appMediated)) + await store.awaitQuiescence() + + #expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one") + #expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked") + } +} + +// MARK: - The Edit-session flag + +/// What the card body still owes the commit model after the stage-around widened to the whole window +/// (06 ▸ Rules ▸ Auto-commit, 2026-07-31): the **flag**, not an announcement. +/// +/// `CardBodyEditSession.editSessionDidChange` was the boundary's announcement, and it went with the +/// widening — the exclusion now opens with the window and releases when the window's session ends, +/// so nothing in production ever wired it (`CardWindowHost.configureSession`). What survives is +/// `isEditing`, which the close path reads as part of "does this window hold unsaved content". +@MainActor +@Suite("Auto-commit ▸ the Edit-session flag") struct EditSessionBoundaryTests { - @Test("Entering and leaving Edit announces the session exactly once each way") - func theBoundaryIsAnnouncedOnce() { + @Test("Entering and leaving Edit moves the flag, and a re-assertion of the mode does not") + func theBoundaryMovesTheFlagOnce() { let session = CardBodyEditSession() let presentation = CardBodyPresentation() presentation.beginEdits = { session.beginEditSession() } presentation.flushEdits = { session.endEditSession() } - var events: [Bool] = [] - session.editSessionDidChange = { events.append($0) } - presentation.setMode(.edit) presentation.setMode(.edit) // a re-published focus value, a menu validation pass session.beginEditSession() // idempotent - presentation.setMode(.preview) - presentation.setMode(.preview) + #expect(session.isEditing) - #expect(events == [true, false]) + presentation.setMode(.preview) + presentation.setMode(.preview) #expect(!session.isEditing) } @@ -859,40 +1060,6 @@ struct EditSessionBoundaryTests { #expect(presentation.openIfNeeded(body: "") == .edit) #expect(session.isEditing) } - - @Test("A window closing from Preview announces nothing") - func closingFromPreviewIsSilent() { - let session = CardBodyEditSession() - var events: [Bool] = [] - session.editSessionDidChange = { events.append($0) } - - // `CardWindowSession.endSession()` calls this on every close, in Edit or not. - session.endEditSession() - #expect(events.isEmpty) - } - - @Test("The session's last keystrokes are on disk before the committer is nudged") - func theFlushPrecedesTheNudge() { - let session = CardBodyEditSession() - var landed: [String] = [] - var textAtNudge: String? - session.save = { text in - landed.append(text) - return .written - } - session.editSessionDidChange = { isEditing in - if !isEditing { textAtNudge = landed.last } - } - - session.beginEditSession() - session.adopt(diskBody: "before") - session.edited("after") - session.endEditSession() - - // A nudge that arrived before the flush would arm a commit carrying the file as it stood one - // keystroke ago. - #expect(textAtNudge == "after") - } } // MARK: - Semantic messages, through the whole engine @@ -1038,6 +1205,84 @@ struct AutoCommitMessageTests { #expect(try headSubject(at: fixture.root) == "Update 'notes.txt'") } + + // MARK: The covering snapshot + + /// One card-window session's worth of state, as the close flush meets it: a change on disk that + /// the app vouched for, and a `store.snapshot` that has not caught up yet. + /// + /// The board's two store reads are faked rather than driven through a real `BoardStore`, and + /// deliberately: what is being pinned is *the order the flush reads them in*, which a real + /// watcher would settle by racing rather than by rule. `landsAfterReads` is the reload landing — + /// the generation asked for the nth time is the walk that finally covers the write. + private func flushRacingItsReload( + awaitsCoverage: Bool, + landsAfterReads: Int = 3 + ) async throws -> String? { + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + // Only the explicit flush runs: a debounce firing mid-wait would be a second flush answering + // the question this test is asking of the first. + committer.debounceInterval = .seconds(30) + committer.coveringSnapshotPollInterval = .milliseconds(1) + committer.coveringSnapshotDeadline = .milliseconds(500) + + // The board as the app last read it — one card, which is what HEAD's tree also says. + var current = try fixture.snapshot() + committer.currentSnapshot = { current } + + // The session's write lands on disk, vouched for, with no reload behind it yet. + let text = plain(order: "2048", title: "Second") + try fixture.item("\(Ident.lane1)/\(Ident.card2)", text) + ledger.recordWrite( + at: fixture.url("\(Ident.lane1)/\(Ident.card2)").appendingPathComponent(BoardLoader.indexFileName), + text: text + ) + committer.noteWriteBracketClosed() + + if awaitsCoverage { + var generation = 0 + var reads = 0 + committer.awaitReloadQuiescence = {} + committer.snapshotGeneration = { + reads += 1 + if reads == landsAfterReads { + current = (try? fixture.snapshot()) ?? current + generation += 1 + committer.noteReloadLanded(sawForeignChange: false) + } + return generation + } + } + + await committer.flushNow() + return try headSubject(at: fixture.root) + } + + /// **"The flush awaits the snapshot that covers it"** (06 ▸ Rules ▸ Auto-commit, ruled + /// 2026-07-31): "the commit's subject can never be outrun by its own reload". + @Test("A close flush racing a stale snapshot composes from the covering one") + func theFlushAwaitsItsCoveringSnapshot() async throws { + #expect(try await flushRacingItsReload(awaitsCoverage: true) == "Add card 'Second'") + } + + /// The same race with the store's two reads unwired — the storeless configuration, and what the + /// close flush did before the ruling. The commit still lands (the condition is the *tree*), but + /// its subject describes a board that has not heard about the card it is committing. + @Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned") + func aStaleSnapshotComposesTheShrug() async throws { + #expect(try await flushRacingItsReload(awaitsCoverage: false) == CommitMessageEngine.unnamedSubject) + } + + /// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and + /// the close path may not hang on one. The commit lands from the snapshot in hand. + @Test("A covering reload that never lands ends the wait rather than the app") + func theWaitIsBounded() async throws { + // The generation never moves, so the wait runs to its (millisecond) deadline and composes. + #expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max) + == CommitMessageEngine.unnamedSubject) + } } // MARK: - Attribution, as a pure function diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index 70ee18b..ea02611 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -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)", diff --git a/KanbanTests/BoardAnnouncerTests.swift b/KanbanTests/BoardAnnouncerTests.swift index 9f58a72..5860aa2 100644 --- a/KanbanTests/BoardAnnouncerTests.swift +++ b/KanbanTests/BoardAnnouncerTests.swift @@ -780,8 +780,9 @@ struct BoardAnnouncerStoreTests { let log = listen(to: store) try store.performWholesale(announcing: "Pulled 3 commits") { - // A lane with no `order` fails the whole load (01-storage-format.md § Malformed input). - try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n") + // A lane written by a newer Lanework fails the whole load (01-storage-format.md + // § Malformed input) — the fail-fast that survived the 2026-07-31 optional-key ruling. + try fixture.item(lane1, "---\nschema: 99\norder: 1024\ntitle: Todo\n---\n\n") } await reload(store, .appMediated) @@ -800,7 +801,7 @@ struct BoardAnnouncerStoreTests { let store = try BoardStore(rootURL: fixture.root) try store.performWholesale { - try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n") + try fixture.item(lane1, "---\nschema: 99\norder: 1024\ntitle: Todo\n---\n\n") } await reload(store, .appMediated) #expect(store.readOnlyLock == .bracketedReloadFailed) diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 3eb55ec..1df902e 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -303,7 +303,8 @@ struct BoardLoaderNonUUIDStrayTests { try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") - // Missing 'order' — would be a fail-fast .missingOrder if this were UUID-shaped. + // A hand-authored lane with a name that isn't identity-shaped: never a candidate, so its + // contents are never read at all. try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n") let result = try BoardLoader.load(boardRoot: fixture.root) @@ -725,54 +726,25 @@ struct BoardLoaderFailFastTests { } } - /// A UUID-shaped folder still fails fast on structurally-bad content — the name shape only - /// gates *candidacy*, never the validity of a folder that qualifies. - @Test func missingOrderOnUUIDLaneThrows() throws { + /// A schema newer than the app fails fast **below** the root too — the one `schema` rule the + /// optional-key ruling left alone (01-storage-format.md § Malformed input, re-ruled 2026-07-31). + @Test func schemaNewerThanAppOnALaneThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let lane = uuidFolderName() try fixture.index("", "schema: 1\n") - try fixture.index(lane, "schema: 1\n") + try fixture.index(lane, "schema: 2\norder: 1024\n") - expectFailure(.missingOrder, path: "\(lane)/index.md") { + expectFailure(.schemaNewerThanApp(found: 2), path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } - @Test func malformedOrderOnUUIDLaneThrows() throws { - let fixture = try BoardFixture() - defer { fixture.tearDown() } - - let lane = uuidFolderName() - - try fixture.index("", "schema: 1\n") - try fixture.index(lane, "schema: 1\norder: not-a-number\n") - - expectFailure(.malformedOrder(raw: "not-a-number"), path: "\(lane)/index.md") { - _ = try BoardLoader.load(boardRoot: fixture.root) - } - } - - /// A non-finite `order` (`.nan`, `.inf`) is the same loud rejection as a non-numeric one - /// (01-storage-format.md § Frontmatter, settled) — NaN has no place in the total order the - /// tie-break and midpoint math assume. - @Test func nonFiniteOrderOnUUIDLaneThrows() throws { - let fixture = try BoardFixture() - defer { fixture.tearDown() } - - let lane = uuidFolderName() - - try fixture.index("", "schema: 1\n") - try fixture.index(lane, "schema: 1\norder: .nan\n") - - expectFailure(.malformedOrder(raw: ".nan"), path: "\(lane)/index.md") { - _ = try BoardLoader.load(boardRoot: fixture.root) - } - } - - @Test func missingOrderOnUUIDCardThrows() throws { + /// A malformed `schema` below the root still refuses: the ruling made the *absent* key optional, + /// not the unreadable one — reading `schema: one` as 1 would be inventing agreement. + @Test func malformedSchemaOnACardThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } @@ -781,14 +753,162 @@ struct BoardLoaderFailFastTests { try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") - try fixture.index("\(lane)/\(card)", "schema: 1\n") + try fixture.index("\(lane)/\(card)", "schema: one\norder: 1024\n") - expectFailure(.missingOrder, path: "\(lane)/\(card)/index.md") { + expectFailure(.malformedSchema(raw: "one"), path: "\(lane)/\(card)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } } +// MARK: - `order` and `schema` optional below the board root + +/// **The append-at-end reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31): below the +/// board root a missing, null, non-numeric or non-finite `order` is no longer a fail-fast — it reads +/// as a rank past every ordered sibling, tie-broken by folder name, and the reading is coerce-tier +/// (logged, bytes preserved). `Fixtures/Valid/optional-keys.kanban` is the disk-backed golden case; +/// these are the synthetic edges. +struct BoardLoaderOptionalOrderTests { + + /// The zero-read minimum the ruling exists for: a lane with one ranked card and one card whose + /// whole frontmatter is a title. + @Test func orderlessCardAppendsAfterEveryRankedSibling() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = "10000000-0000-4000-8000-000000000001" + let ranked = "20000000-0000-4000-8000-000000000001" + // Deliberately the *lower* folder name, so folder order alone would put it first. + let orderless = "10000000-0000-4000-8000-000000000009" + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(ranked)", "schema: 1\norder: 4096\n") + try fixture.index("\(lane)/\(orderless)", "title: Minimum\n") + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes[0].cards.map(\.id.rawValue) == [ranked, orderless]) + #expect(model.lanes[0].cards.map(\.order) == [4096, 5120]) + } + + /// Two order-less siblings: folder name decides, and the ranks they read as are `append`'s own + /// ladder — which is what lets the Writer stamp them without anything moving. + @Test func twoOrderlessSiblingsSortByFolderName() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = "10000000-0000-4000-8000-000000000001" + let second = "30000000-0000-4000-8000-000000000002" + let first = "20000000-0000-4000-8000-000000000001" + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(second)", "schema: 1\n") + try fixture.index("\(lane)/\(first)", "schema: 1\n") + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes[0].cards.map(\.id.rawValue) == [first, second]) + // No ranked sibling at all, so the ladder bases at 0 — the empty-container convention. + #expect(model.lanes[0].cards.map(\.order) == [1024, 2048]) + } + + /// The four unusable shapes are one reading. Each is a coercion carrying the text as written. + @Test func everyUnusableOrderShapeReadsAsAppendAtEnd() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = "10000000-0000-4000-8000-000000000001" + let anchor = "20000000-0000-4000-8000-000000000000" + let shapes: [(id: String, frontmatter: String, raw: String)] = [ + ("20000000-0000-4000-8000-000000000001", "schema: 1\n", ""), + ("20000000-0000-4000-8000-000000000002", "schema: 1\norder:\n", ""), + ("20000000-0000-4000-8000-000000000003", "schema: 1\norder: null\n", "null"), + ("20000000-0000-4000-8000-000000000004", "schema: 1\norder: banana\n", "banana"), + ("20000000-0000-4000-8000-000000000005", "schema: 1\norder: .nan\n", ".nan"), + ("20000000-0000-4000-8000-000000000006", "schema: 1\norder: .inf\n", ".inf"), + ] + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(anchor)", "schema: 1\norder: 2048\n") + for shape in shapes { + try fixture.index("\(lane)/\(shape.id)", shape.frontmatter) + } + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [anchor] + shapes.map(\.id)) + #expect(result.model.lanes[0].cards.map(\.order) == [2048, 3072, 4096, 5120, 6144, 7168, 8192]) + + let coerced = Dictionary( + uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) }) + for shape in shapes { + #expect( + coerced["\(lane)/\(shape.id)/index.md"] == [CoercedField(key: "order", raw: shape.raw)], + "\(shape.frontmatter) should coerce with raw '\(shape.raw)'" + ) + } + } + + /// The rule holds one level up: an order-less lane sits right of every ranked one. + @Test func orderlessLaneAppendsAtTheEndOfTheStrip() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let ranked = "90000000-0000-4000-8000-000000000001" + let orderless = "10000000-0000-4000-8000-000000000002" + + try fixture.index("", "schema: 1\n") + try fixture.index(ranked, "schema: 1\norder: 1024\n") + try fixture.index(orderless, "schema: 1\n") + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes.map(\.id.rawValue) == [ranked, orderless]) + #expect(model.lanes.map(\.order) == [1024, 2048]) + } + + /// A missing `schema` below the root reads as 1 and records a coercion; the **root's** own + /// missing `schema` is still the loud rejection (`missingSchemaThrows` above). + @Test func missingSchemaBelowRootReadsAsOne() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = uuidFolderName() + let card = uuidFolderName() + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "order: 1024\ntitle: No Schema Lane\n") + try fixture.index("\(lane)/\(card)", "order: 1024\ntitle: No Schema Card\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes[0].schema == 1) + #expect(result.model.lanes[0].cards[0].schema == 1) + + let paths = Set(result.coercedFrontmatter.map(\.path)) + #expect(paths == ["\(lane)/index.md", "\(lane)/\(card)/index.md"]) + #expect(result.coercedFrontmatter.allSatisfy { $0.fields == [CoercedField(key: "schema", raw: "")] }) + } + + /// A trash entry without a rank reads like every other order-less file. `order` decides nothing + /// about where a trash row sits — `modified` does — so this is only about the rank it carries + /// back out on a restore. + @Test func orderlessTrashEntryReadsAsAppendAtEnd() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let ranked = "20000000-0000-4000-8000-000000000001" + let orderless = "10000000-0000-4000-8000-000000000002" + + try fixture.index("", "schema: 1\n") + try fixture.index(".trash/\(ranked)", "schema: 1\nkind: card\norder: 1024\n") + try fixture.index(".trash/\(orderless)", "schema: 1\nkind: card\n") + + let model = try BoardLoader.load(boardRoot: fixture.root).model + let byID = Dictionary(uniqueKeysWithValues: model.trash.map { ($0.id.rawValue, $0.order) }) + #expect(byID[ranked] == 1024) + #expect(byID[orderless] == 2048) + } +} + // MARK: - Encoding strictness /// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 7c09651..94f8229 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -465,32 +465,54 @@ struct BoardWriterRenumberTests { #expect(try fixture.indexData("lane/notes") == stray) } - /// A renumber runs over loaded, valid children: one broken sibling fails the whole - /// operation, and it fails before anything has been rewritten. - @Test func aChildWithAMalformedOrderFailsTheWholeRenumber() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) - try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: banana\ntitle: B\n---\nbody\n") - let untouched = try fixture.indexData("lane/\(Child.a)") + /// **An order-less sibling takes part in the rescale rather than stopping it** + /// (01-storage-format.md § Ordering, re-ruled 2026-07-31): it joins the batch on its + /// append-at-end reading, so it comes out of the renumber holding a real rank and sitting exactly + /// where the board was already drawing it — last. + @Test func aChildWithAnUnusableOrderJoinsTheRenumberAtTheEnd() throws { + for unusable in ["order: banana", "order: .nan", "order:"] { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + // `b` sorts after `a` by folder name, but it is the *rank* that puts it last here: `a` + // carries one and `b` does not. + try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) + try fixture.item("lane/\(Child.b)", "---\nschema: 1\n\(unusable)\ntitle: B\n---\nbody\n") - let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } - #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) - #expect(error?.path.contains(Child.b) == true) - #expect(error?.operation == .renumberChildren) - #expect(try fixture.indexData("lane/\(Child.a)") == untouched) + try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) + + #expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)")).order == .valid(1024), "\(unusable)") + #expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.b)")).order == .valid(2048), "\(unusable)") + } } - @Test func aChildWithNoOrderFailsTheWholeRenumber() throws { + /// The order-less sibling sorts *after* every ranked one even when its folder name would put it + /// first — the whole of the append-at-end reading, seen through the rescale. + @Test func anOrderlessChildRenumbersLastRegardlessOfFolderName() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("lane/\(Child.a)", "---\nschema: 1\ntitle: A\n---\nbody\n") + try fixture.item("lane/\(Child.b)", child(order: "512", title: "B")) + try fixture.item("lane/\(Child.c)", child(order: "1536", title: "C")) + + try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) + + #expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.b)")).order == .valid(1024)) + #expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.c)")).order == .valid(2048)) + #expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)")).order == .valid(3072)) + } + + /// A renumber still runs over *readable* children: a sibling that refuses writes fails the whole + /// operation before anything has been rewritten. + @Test func aChildWithAnUnreadableIndexFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) - try fixture.item("lane/\(Child.b)", "---\nschema: 1\ntitle: B\n---\nbody\n") + try fixture.item("lane/\(Child.b)", bytes: Data([0xFF, 0xFE, 0x00])) let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } - #expect(error?.reason == .unreadable(message: "missing required 'order' field")) #expect(error?.path.contains(Child.b) == true) + #expect(error?.operation == .renumberChildren) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @@ -515,6 +537,155 @@ struct BoardWriterRenumberTests { } } +// MARK: - The rank stamps + +/// **The write side of the optional-`order` ruling** (01-storage-format.md § Ordering, re-ruled +/// 2026-07-31): "The rank materializes on touch — the first Writer rewrite of the file stamps a real +/// rank, and placement math that must rank an item *relative to* an order-less sibling stamps that +/// sibling inline, inside the gesture's bracket and commit." +/// +/// Both stamps write `Ranks.resolvedOrders`' own answer — the rank the loader was already rendering +/// the file at — so every assertion here is also an assertion that nothing moved. +struct BoardWriterRankStampTests { + + private func order(_ fixture: WriterFixture, _ path: String) throws -> FieldValue { + try FrontmatterDocument.parse(fixture.indexText(path)).order + } + + /// The on-touch half: any rewrite at all — here an ordinary title edit — stamps the rank. + @Test func aRewriteOfAnOrderlessFileStampsItsRank() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n") + let folder = try fixture.item("\(Ident.lane1)/\(Child.b)", "---\ntitle: Minimum\n---\nbody\n") + + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in + document.set(FrontmatterKeys.title, to: .string("Renamed")) + } + + // 1024 (the ranked sibling) + one gap — where the board was already drawing it. + #expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048)) + // The `kind` backfill rides the same write, and the ranked sibling is untouched. + #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Child.b)")).kind == .valid("card")) + #expect(try order(fixture, "\(Ident.lane1)/\(Child.a)") == .valid(1024)) + } + + /// An unusable value heals the same way — the reading is stated over usability, so the stamp is. + @Test func aRewriteOfAnUnusableOrderStampsOverIt() throws { + for unusable in ["order: banana", "order: .nan"] { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let folder = try fixture.item( + "\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\n\(unusable)\n---\nbody\n") + + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in + document.set(FrontmatterKeys.title, to: .string("Renamed")) + } + + #expect(try order(fixture, "\(Ident.lane1)/\(Child.a)") == .valid(1024), "\(unusable)") + } + } + + /// **The board root never gains a rank** — it has no siblings to sit among, and `order` is + /// meaningless there. + @Test func theBoardRootIsNeverStamped() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = try fixture.item("board", "---\nschema: 1\nkind: board\ntitle: Board\n---\nbody\n") + + try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: .style(title: nil)) { document in + document.set(FrontmatterKeys.title, to: .string("Renamed")) + } + + #expect(try order(fixture, "board") == .missing) + } + + /// **The inline half**: a rank write is placement math landing, so the container's order-less + /// siblings are stamped with the reading the placement was computed against — inside the same + /// call, which is inside the caller's bracket and commit. + @Test func aRankWriteStampsItsOrderlessSiblingsInline() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n") + try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\ntitle: B\n---\nbody\n") + let c = try fixture.item("\(Ident.lane1)/\(Child.c)", "---\nschema: 1\nkind: card\ntitle: C\n---\nbody\n") + + // The reading is A=1024, B=2048, C=3072. A drop between B and C is their midpoint — an + // answer that is only true on disk if B and C hold those ranks. + try BoardWriter.updateIndex(inItemFolder: c, operation: .reorder(title: nil)) { document in + document.set(FrontmatterKeys.order, to: .double(2560)) + } + + #expect(try order(fixture, "\(Ident.lane1)/\(Child.a)") == .valid(1024)) + #expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048)) + #expect(try order(fixture, "\(Ident.lane1)/\(Child.c)") == .valid(2560)) + } + + /// The pass is idempotent and silent on a board this app wrote: nothing order-less, nothing + /// written, and no sibling's bytes touched. + @Test func aRankWriteTouchesNoRankedSibling() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n") + let b = try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\norder: 2048\ntitle: B\n---\nbody\n") + let untouched = try fixture.indexData("\(Ident.lane1)/\(Child.a)") + + try BoardWriter.updateIndex(inItemFolder: b, operation: .reorder(title: nil)) { document in + document.set(FrontmatterKeys.order, to: .double(512)) + } + + #expect(try fixture.indexData("\(Ident.lane1)/\(Child.a)") == untouched) + } + + /// A create appends *after* the order-less sibling rather than above it — which needs that + /// sibling stamped first, since `createChild` mints a file instead of rewriting one. + @Test func aCreateStampsTheOrderlessSiblingItAppendsPast() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", "---\nschema: 1\nkind: board\n---\n") + let lane = try fixture.item(Ident.lane1, "---\nschema: 1\nkind: lane\norder: 1024\n---\n") + try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n") + try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\ntitle: B\n---\nbody\n") + + let created = try BoardWriter.createCard(inLane: lane, title: "New") + + #expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048)) + #expect(try order(fixture, "\(Ident.lane1)/\(created.rawValue)") == .valid(3072)) + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes[0].cards.map(\.title.value) == ["A", "B", "New"]) + } + + /// A move into a lane stamps the destination's order-less children **before the folder lands**, + /// so the reading it stamps is the one the caller's placement was computed against — not one + /// re-based against the arriving item's own foreign rank. + @Test func aMoveStampsTheDestinationBeforeTheFolderArrives() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", "---\nschema: 1\nkind: board\n---\n") + try fixture.item("\(Ident.lane1)", "---\nschema: 1\nkind: lane\norder: 1024\n---\n") + try fixture.item("\(Ident.lane2)", "---\nschema: 1\nkind: lane\norder: 2048\n---\n") + try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n") + try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\ntitle: B\n---\nbody\n") + // The arriving card carries a rank far above anything in the destination — the value that + // would poison the reading if the stamp ran after the move. + try fixture.item("\(Ident.lane2)/\(Child.c)", "---\nschema: 1\nkind: card\norder: 99999\ntitle: C\n---\nbody\n") + + // The reading in lane1 is A=1024, B=2048; the drop between them is their midpoint. + _ = try BoardWriter.moveItem( + at: fixture.url("\(Ident.lane2)/\(Child.c)"), + toParent: fixture.url("\(Ident.lane1)"), + sourceBoardRoot: fixture.root, + destinationBoardRoot: fixture.root, + order: 1536 + ) + + #expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048)) + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes[0].cards.map(\.title.value) == ["A", "C", "B"]) + } +} + // MARK: - Loader integration struct BoardWriterLoaderIntegrationTests { @@ -747,20 +918,24 @@ struct BoardWriterCreateChildTests { #expect(Set(result.model.lanes.map(\.id.rawValue)) == Set([Child.a, newID.rawValue])) } - @Test func aSiblingWithAMalformedOrderFailsTheCreateNamingTheSibling() throws { + /// **A sibling with an unusable `order` no longer fails the create** (01-storage-format.md + /// § Ordering, re-ruled 2026-07-31): it reads as append-at-end, gets stamped with that reading + /// inline, and the new lane appends past it — which is what "append after the current visible + /// siblings" has to mean for the result to survive a reload. + @Test func aSiblingWithAnUnusableOrderIsStampedAndAppendedPast() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } - try fixture.item(Child.a, "---\nschema: 1\norder: banana\ntitle: A\n---\nbody\n") - let before = try fixture.entryNames("") + try fixture.item("", "---\nschema: 1\nkind: board\ntitle: Board\n---\n") + try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\ntitle: Ranked\n---\nbody\n") + try fixture.item(Ident.lane2, "---\nschema: 1\norder: banana\ntitle: Unusable\n---\nbody\n") - let error = writeFailure { - _ = try BoardWriter.createLane(inBoard: fixture.root, title: "New") - } - #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) - #expect(error?.path.contains(Child.a) == true) - #expect(error?.operation == .createLane) - // Nothing was minted: the scan fails before the new folder is ever created. - #expect(try fixture.entryNames("") == before) + let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + + #expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).order == .valid(2048)) + #expect(try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)).order == .valid(3072)) + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes.map(\.title.value) == ["Ranked", "Unusable", "New"]) } @Test func aMissingParentFolderIsALoudUnreadableError() throws { diff --git a/KanbanTests/CardSessionUndoTests.swift b/KanbanTests/CardSessionUndoTests.swift index 3151050..f672eda 100644 --- a/KanbanTests/CardSessionUndoTests.swift +++ b/KanbanTests/CardSessionUndoTests.swift @@ -33,13 +33,31 @@ private struct Window { private let cardID = ItemID(rawValue: Ident.card1) private let cardPath = "\(Ident.lane1)/\(Ident.card1)" +/// **The coarse close step's row, spelled once** — "Changes to '⟨card⟩'" (13-native-undo.md ▸ Rules, +/// ruled 2026-07-31), over the fixture card's own title. Pinned as a value here and asserted verbatim +/// in `theCoarseStepNamesItsCard` below, so a suite that reads the phrase eleven times still only +/// *decides* it once. +private let coarseStep = "Changes to 'Fix login'" + /// A card window over a board with one card, wired exactly as `CardWindowHost` wires one. @MainActor private func makeWindow(_ fixture: WriterFixture) throws -> Window { let store = try BoardStore(rootURL: fixture.root) let board = NativeHistoryProvider() store.history = board + return try openWindow(fixture, store: store, board: board) +} +/// **The same card opened again, over the board that is already holding the last session's step** — +/// the reopen 13's sweep gate is about (ruled 2026-07-31): a second window is a second session with +/// its own empty stack, and the *board's* stack is the one that survived the close. +@MainActor +private func reopen(_ window: Window, _ fixture: WriterFixture) throws -> Window { + try openWindow(fixture, store: window.store, board: window.board) +} + +@MainActor +private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: NativeHistoryProvider) throws -> Window { let session = CardWindowSession() CardWindowHost.configureUndo(session, store: store, cardID: cardID) CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) @@ -221,7 +239,7 @@ struct CardSessionCloseTests { // One step, named for the session rather than for any gesture inside it. #expect(window.board.canUndo) - #expect(window.board.undoActionName == "Edit Card") + #expect(window.board.undoActionName == coarseStep) window.board.undo() #expect(!window.board.canUndo, "exactly one") @@ -238,6 +256,31 @@ struct CardSessionCloseTests { #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) } + @Test("The coarse step is named for its card — 'Changes to ⟨title⟩', never the fine 'Edit Card'") + func theCoarseStepNamesItsCard() async throws { + // 13-native-undo.md ▸ Rules, ruled 2026-07-31: "one coarse step named 'Changes to ⟨card⟩' — + // the board row reads 'Undo Changes to ⟨Fix login⟩': plural and scope-flavoured, distinct from + // every fine verb … the fine body-edit wording never leaks onto the board menu." + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + + editBody(window, to: "Edited in the window.\n") + #expect(window.window.stack.undoActionName == "Edit Card", + "the fine body edit keeps its verb — this is the collision the ruling resolves") + + await window.session.endSession() + #expect(window.board.undoActionName == "Changes to 'Fix login'") + #expect(window.board.undoActionName == coarseStep) + // The "Undo " prefix is the platform's, composed over the bare phrase (`BoardUndoManager`). + #expect(BoardUndoManager(history: window.board).undoMenuItemTitle == "Undo Changes to 'Fix login'") + + // An untitled card renders the placeholder its own window title bar renders — "Untitled" is a + // rendering, never a value (03-board-ui.md § Card face). + #expect(HistoryPhrase.cardSession(nil) == "Changes to 'Untitled'") + } + @Test("A session with no net change registers nothing") func noNetChangeRegistersNothing() async throws { let fixture = try WriterFixture() @@ -291,7 +334,7 @@ struct CardSessionCloseTests { window.comments.commitEdit() window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() - #expect(window.board.undoActionName == "Edit Card") + #expect(window.board.undoActionName == coarseStep) window.board.undo() #expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival") @@ -340,7 +383,7 @@ struct CardSessionCloseTests { // "A tracked relocation — a lane move mid-session or after close, **a trash move** — never // stales the step" (13 ▸ Rules, ruled 2026-07-31): the session resolves through the walk that // spans both containers, so the step registers over the delete rather than being dropped. - #expect(window.board.undoActionName == "Edit Card") + #expect(window.board.undoActionName == coarseStep) window.board.undo() #expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed") @@ -384,7 +427,7 @@ struct CardSessionCloseTests { #expect(!window.board.canUndo) await window.session.endSession() - #expect(window.board.undoActionName == "Edit Card") + #expect(window.board.undoActionName == coarseStep) window.board.undo() let document = try FrontmatterDocument.parse(fixture.indexText(cardPath)) @@ -500,7 +543,7 @@ struct CardSessionAnchorTests { #expect(window.board.undoActionName == "Move Card") await window.session.endSession() - #expect(window.board.undoActionName == "Edit Card", "the session registered over the move") + #expect(window.board.undoActionName == coarseStep, "the session registered over the move") window.board.undo() #expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision") @@ -544,8 +587,8 @@ struct CardSessionAnchorTests { #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write") #expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"), "and the comment half did not half-happen either") - #expect(try fixture.entryNames("\(cardPath)/comments/.trash").isEmpty, - "the skipped step retired, so the backing it was holding was purged with it") + #expect(fixture.exists("\(cardPath)/comments/.trash/\(CommentIdent.one)"), + "the skip is not a clean exit — the backing it held survives to the session's end") #expect(!window.board.canUndo, "both steps are gone — one skipped, one applied") } @@ -589,6 +632,34 @@ struct CardSessionPurgeTests { return (window, card) } + /// The same close, collided with — **the coarse step popped as stale, having applied nothing**, + /// which is the state the skip-purge decoupling is about (13 ▸ Interaction with the trash, ruled + /// 2026-07-31). + /// + /// **The body edit is what makes this a skip at all.** A session of nothing but the delete names + /// only the comment, and a foreign write to the *card* would leave it perfectly current — the undo + /// would then apply, restore the comment, and empty the trash by moving its one entry out, which + /// is the same disk state for entirely the wrong reason. With the body in the step, a foreign body + /// rewrite stales it **without touching what the purge would remove**, which is the only way to + /// ask what the skip did to the backing. + @MainActor + private func skippedOnAForeignEdit(_ fixture: WriterFixture) async throws -> (Window, String) { + let card = try makeCommentBoard(fixture) + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + let window = try makeWindow(fixture) + window.comments.reload() + editBody(window, to: "Edited in the window.\n") + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + await window.session.endSession() + + _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n") + window.board.undo() + #expect(window.store.banners.signposts.map(\.message) + == ["Undo skipped — 'Fix login' changed outside Lanework"]) + #expect(!window.board.canUndo, "the stale step was popped") + return (window, card) + } + @Test("comments/.trash survives the close while the coarse step lives") func theTrashOutlivesTheClose() async throws { let fixture = try WriterFixture() @@ -600,6 +671,64 @@ struct CardSessionPurgeTests { "the step's undo restores from here — the purge waits for it") } + // MARK: The reopen — 13's sweep gate + + @Test("Reopening the card window never destroys the last session's undo backing") + func theReopenSweepSparesTheCoarseStepsBacking() async throws { + // The ship-blocker this gate exists for (13 ▸ Interaction with the trash, ruled 2026-07-31): + // the open-time residue sweep used to empty `comments/.trash/` unconditionally, so opening the + // card again threw away the folder the board's own coarse step was about to restore from. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await closedWithADeletedComment(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + + // The reopen. `CardComments.open()` runs the sweep before it reads the thread, exactly as the + // host wires it — so this is the production sequence, not a re-typed copy of it. + _ = try reopen(window, fixture) + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), + "the sweep asked the board's stack first: this is backing, not residue") + + window.board.undo() + #expect(window.store.banners.signposts.isEmpty, "nothing was stale — the backing was still there") + #expect(fixture.exists(path), "the coarse step's undo restored the comment the reopen spared") + } + + @Test("Residue no live step owns still sweeps at the open, beside backing that does") + func unownedResidueStillSweeps() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await closedWithADeletedComment(fixture) + // A crashed session's leftovers, landing beside the live step's backing: nothing on the stack + // names this one, so it is residue by the ruling's own definition. + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + + _ = try reopen(window, fixture) + + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "unowned content sweeps as before") + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "and owned content is left alone") + #expect(window.store.heals.memo(for: .commentTrashResidue) == nil, + "the signature is the entries actually purged, and it cleared on success") + } + + @Test("Once the hold ends, the purge the sweep deferred to runs") + func theRetirementRunsThePurgeTheSweepDeferredTo() async throws { + // "One condition, two consumers" (13): the sweep spared this content because a step owned it, + // and the moment that ownership ends is the moment the deferred purge was always waiting for. + // The arc — spared, then released, then purged — is what this proves; *which* release ends it + // is the two tests below and the two below the divider. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await closedWithADeletedComment(fixture) + + _ = try reopen(window, fixture) + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "spared by the reopen's sweep") + + window.board.clear() + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, + "and the purge the sweep had deferred to ran with it") + } + @Test("The board session's end purges what the step was holding") func theBoardSessionsEndPurges() async throws { let fixture = try WriterFixture() @@ -611,19 +740,63 @@ struct CardSessionPurgeTests { #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } - @Test("A stale step's skip purges too — the step is gone, so its backing is not needed") - func aSkippedStepPurges() async throws { + // MARK: The skip — the one exit that is not clean + + @Test("A stale skip purges nothing — the backing it was holding survives") + func aSkippedStepsBackingSurvives() async throws { + // The decoupling ruled 2026-07-31 (13 ▸ Interaction with the trash), and its reason: "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". This is the pin that used to say the opposite. let fixture = try WriterFixture() defer { fixture.tearDown() } - let (window, card) = try await closedWithADeletedComment(fixture) + let (_, card) = try await skippedOnAForeignEdit(fixture) - // A foreign delete of the trashed folder is not the interesting collision; a foreign body - // rewrite is — it makes the step stale without touching what the purge would remove. - _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n") - // The session wrote the body too, so the step names it. - window.board.undo() + #expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)), + "the step skipped rather than applied — nothing was restored") + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), + "and nothing was destroyed either: the bytes are still there to look at") + } - #expect(!window.board.canUndo) + @Test("Reopening the card after a skip spares the survivor — the same hold, read twice") + func theSweepSparesASkipSurvivor() async throws { + // The half that makes "survives to board-session end" true rather than merely intended: the + // step is off both stacks, so a sweep that read only those would call this residue and purge + // it at the very next open — taking the ruling back one window later. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await skippedOnAForeignEdit(fixture) + + _ = try reopen(window, fixture) + + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), + "the sweep asked the same hold the purge is still waiting on") + } + + @Test("Unowned residue beside a skip's survivor still sweeps") + func residueSweepsBesideASkipSurvivor() async throws { + // The gate did not become "spare everything in there": a stranded step's claim is exactly as + // narrow as a live one's, one anchor at a time. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await skippedOnAForeignEdit(fixture) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + + _ = try reopen(window, fixture) + + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the survivor is owned") + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "the crash leftover is not") + } + + @Test("The board session's end purges a skip's survivor too") + func theBoardSessionsEndPurgesASkipSurvivor() async throws { + // "A stale-skipped step's backing instead survives **to board-session end**" — the deferral + // has a floor, and it is the same one every other hold has (`AppModel`'s teardown). + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await skippedOnAForeignEdit(fixture) + + window.board.clear() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @@ -668,7 +841,7 @@ struct CardSessionPurgeTests { // gesture leaves history for good, and its retirement runs (13 ▸ Interaction with the trash). let provider = NativeHistoryProvider() let retirement = HistoryStep.Retirement {} - provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) + provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) #expect(retirement.isOwed) provider.undo() @@ -683,7 +856,7 @@ struct CardSessionPurgeTests { var runs = 0 let retirement = HistoryStep.Retirement { runs += 1 } let provider = NativeHistoryProvider() - provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) + provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) provider.clear() provider.clear() diff --git a/KanbanTests/CommentThreadTests.swift b/KanbanTests/CommentThreadTests.swift index 6f172f1..dcc40ed 100644 --- a/KanbanTests/CommentThreadTests.swift +++ b/KanbanTests/CommentThreadTests.swift @@ -273,17 +273,23 @@ struct CommentDefectToleranceTests { @Suite("Comments ▸ the kind: comment field table") struct CommentFieldTableTests { - @Test("schema is the only required field — never order, never title") + /// A comment carries no `order` and never gains one ("Ordering is chronology, not ranks"), and + /// since 2026-07-31 its `schema` is optional too — it sits below the board root like everything + /// else, so an absent one reads as 1. What still refuses is a schema **newer than this app**, + /// which is the one `schema` rule that is level-blind. + @Test("No field is required — order is meaningless here, schema defaults to 1") func requiredFields() throws { - #expect(IntegrityRules.requiresOrder(.comment) == false) - - let withoutOrder = Data("---\nschema: 1\nkind: comment\n---\nbody\n".utf8) - #expect(throws: Never.self) { - try IntegrityRules.validateIndex(withoutOrder, path: "index.md", kind: .comment, supportedSchema: 1) + for frontmatter in ["schema: 1\nkind: comment", "kind: comment"] { + #expect(throws: Never.self) { + try IntegrityRules.validateIndex( + Data("---\n\(frontmatter)\n---\nbody\n".utf8), + path: "index.md", kind: .comment, supportedSchema: 1 + ) + } } - let withoutSchema = Data("---\nkind: comment\n---\nbody\n".utf8) + let newer = Data("---\nschema: 99\nkind: comment\n---\nbody\n".utf8) #expect(throws: BoardLoadError.self) { - try IntegrityRules.validateIndex(withoutSchema, path: "index.md", kind: .comment, supportedSchema: 1) + try IntegrityRules.validateIndex(newer, path: "index.md", kind: .comment, supportedSchema: 1) } } diff --git a/KanbanTests/CommentWriteTests.swift b/KanbanTests/CommentWriteTests.swift index 3f74779..2658e79 100644 --- a/KanbanTests/CommentWriteTests.swift +++ b/KanbanTests/CommentWriteTests.swift @@ -402,6 +402,33 @@ struct CommentResidueTests { #expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success") } + @Test("Content a live step still backs is not residue — the sweep asks the stack first") + func aLiveStepsBackingIsNotResidue() throws { + // 13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31: "`comments/.trash/` + // content referenced by a live coarse step on the board stack is a step's backing, not + // residue — the open-time sweep consults the stack and skips owned content". The gate is over + // *steps*, not over the coarse one by name, so the shortest way to hold a live step is the + // window-less delete (`deleteComment`'s `nil` window, which is the board's own stack). + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + let (store, history) = try makeStore(fixture) + let cardID = ItemID(rawValue: Ident.card1) + + #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID)) + store.sweepCommentTrashResidue(inCard: cardID) + + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), + "the delete step's undo is the move back out — this is its backing") + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), + "and the entry no step names swept as before") + + history.undo() + #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so ⌘Z still has something to restore") + } + @Test("An open with nothing to sweep rests — no bracket, no memo") func cleanOpenRests() throws { let fixture = try WriterFixture() diff --git a/KanbanTests/CommitMessageTests.swift b/KanbanTests/CommitMessageTests.swift index 2e20dc8..1da0aea 100644 --- a/KanbanTests/CommitMessageTests.swift +++ b/KanbanTests/CommitMessageTests.swift @@ -109,7 +109,11 @@ private func compose( isRootCommit: false, snapshot: try after.snapshot(), previousSnapshot: try before.snapshot(), - agentGuideText: guideText + agentGuideText: guideText, + // Resolved the way a flush resolves it — off the "after" tree, through the committer's own + // reader — rather than hand-assembled, for the same reason both snapshots are loaded rather + // than built: a map the flush could never produce would prove nothing about the flush. + commentTimestamps: GitAutoCommitter.commentTimestamps(for: paths, boardRoot: after.root) )) } @@ -201,6 +205,37 @@ struct CommitMessageSingleEventTests { #expect(message == "Remove 'spec.pdf' from card 'Fix login'") } + @Test("A rewritten attachment composes Replace — never the anonymous path generic") + func replacingAFile() throws { + // Added 2026-07-31: "a changed file under a card's `attachments/` with an unchanged listing is + // a content replacement, named from the path alone". The listing is unchanged here — same + // name, new bytes — so the snapshot diff has nothing to say and the path says it instead. + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8)) + } change: { fixture in + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8)) + } + #expect(message == "Replace attachment 'photo.png' — card 'Fix login'") + } + + @Test("Two replaced attachments on one card fold plural, still naming the card") + func replacingSeveralFiles() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("old".utf8)) + } change: { fixture in + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("new".utf8)) + } + #expect(subject(of: message) == "Replace 2 attachments — card 'Fix login'") + #expect(body(of: message) == [ + "- Replace attachment 'photo.png' — card 'Fix login'", + "- Replace attachment 'spec.pdf' — card 'Fix login'", + ]) + } + @Test("A repositioned card composes Reorder, naming its lane") func reorderingCards() throws { // A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else @@ -378,16 +413,56 @@ struct CommitMessageExternalSurfaceTests { #expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'") } - @Test("An unmodeled custom key composes a named generic — never a board-level shrug") - func customKeysComposeANamedGeneric() throws { + @Test("An unmodeled custom key says what it is, with its values in the body") + func customKeysSayWhatTheyAre() throws { + // Re-ruled 2026-07-31: the named generic ("Update card 'X'") is retired here — "first lines + // self-describe; generics are a last resort". One key, so the singular. let message = try compose { fixture in try fixture.item( "\(Ident.lane1)/\(Ident.card1)", - "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\nestimate: 3\n---\n\n" + "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n" ) } - // Two custom keys, one event: the item is what is named, not the keys. - #expect(message == "Update card 'Fix login'") + #expect(subject(of: message) == "Change custom key on card 'Fix login'") + #expect(body(of: message) == ["sprint: (none) → 42"]) + } + + @Test("Several custom keys fold plural on one item, each named with its old → new values") + func customKeysFoldPlural() throws { + let message = try compose { fixture in + try baseBoard(fixture) + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 41\nestimate: 3\n---\n\n" + ) + } change: { fixture in + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n" + ) + } + // Two keys, one event — the item is still what the subject names — and the body carries both + // sides of each, a removal reading as a move to absence. + #expect(subject(of: message) == "Change 2 custom keys on card 'Fix login'") + #expect(body(of: message) == ["estimate: 3 → (none)", "sprint: 41 → 42"]) + } + + @Test("A lane's and the board's custom keys name their item too — never a board-level shrug") + func customKeysNameLanesAndTheBoard() throws { + let lane = try compose { fixture in + try fixture.item( + Ident.lane1, + "---\nschema: 1\ntitle: Todo\norder: 1024\nwip-limit: 5\n---\n\n" + ) + } + #expect(subject(of: lane) == "Change custom key on lane 'Todo'") + + // The board is the case 06 calls out by name: "never a board-level shrug when the touched item + // is identifiable" — and the board is identifiable, by its own title. + let board = try compose { fixture in + try fixture.item("", "---\nschema: 1\ntitle: Board\nsprint-length: 2w\n---\nBoard description.\n") + } + #expect(subject(of: board) == "Change custom key on board 'Board'") } @Test("A foreign change composes identically to an app-mediated one") @@ -750,6 +825,103 @@ struct CommitMessageCommentTests { } #expect(message == "Delete card 'Fix login'") } + + // MARK: The chronology + + /// A comment's `index.md` with a `created` stamp — the one field the ordering below reads, written + /// in the YAML 1.1 timestamp grammar the loader accepts (`FrontmatterFields.date`). + private static func datedComment(_ body: String, created: String) -> Data { + Data("---\nschema: 1\nkind: comment\nauthor: Ada\ncreated: \(created)\n---\n\(body)\n".utf8) + } + + /// **"A commit's comment bullets sort chronologically … never UUID-arbitrary"** (06 ▸ Rules + /// ▸ Auto-commit, blessed 2026-07-31). + /// + /// Three comments on one card, each with a *different verb* so the bullets are distinguishable, + /// and identities deliberately ordered against their chronology: the earliest comment carries the + /// middle UUID and the latest carries the smallest. Folder-name order would read Edit, Comment, + /// Delete; the conversation happened in the other order, and that is what the body says. + @Test("Comment bullets read in the order the conversation did, not in UUID order") + func commentBulletsSortByCreated() throws { + let card = "\(Ident.lane1)/\(Ident.card1)" + let message = try compose { fixture in + try baseBoard(fixture) + // Posted first, and edited in this window: the latest `created`, the smallest UUID. + try fixture.file( + Self.thread(Ident.card1, Self.commentA), + Self.datedComment("First draft.", created: "2026-07-31T12:00:00Z") + ) + // Deleted in this window, so it exists before and moves into `comments/.trash/`. + try fixture.file( + Self.thread(Ident.card1, Self.commentC), + Self.datedComment("Regretted.", created: "2026-07-31T11:00:00Z") + ) + } change: { fixture in + try fixture.file( + Self.thread(Ident.card1, Self.commentA), + Self.datedComment("Second thoughts.", created: "2026-07-31T12:00:00Z") + ) + // Posted in this window — the earliest `created`, the middle UUID. + try fixture.file( + Self.thread(Ident.card1, Self.commentB), + Self.datedComment("Said hours ago.", created: "2026-07-31T10:00:00Z") + ) + try fixture.moveFolder( + "\(card)/comments/\(Self.commentC)", + to: "\(card)/comments/.trash/\(Self.commentC)" + ) + } + + #expect(body(of: message) == [ + "- Comment on 'Fix login'", + "- Delete comment on 'Fix login'", + "- Edit comment on 'Fix login'", + ]) + } + + /// "**folder name on ties**" — and the name that breaks the tie is the *comment's* folder, not the + /// composite key the groups are gathered under. Two cards, one timestamp: the comment named + /// `…0001` speaks first even though its card sorts second. + @Test("Comments created at the same moment fall back to folder name, never to the card's path") + func tiesFallBackToTheFolderName() throws { + let stamp = "2026-07-31T09:30:00Z" + let message = try compose { fixture in + // On the *first* card, the larger identity. + try fixture.file( + Self.thread(Ident.card1, Self.commentC), + Self.datedComment("On Fix login.", created: stamp) + ) + // On the second card, the smaller one. + try fixture.file( + Self.thread(Ident.card2, Self.commentA), + Self.datedComment("On Ship it.", created: stamp) + ) + } + + #expect(body(of: message) == [ + "- Comment on 'Ship it'", + "- Comment on 'Fix login'", + ]) + } + + /// The undated sort **after** the dated — `CommentThread.sorted`'s own fallback, applied one layer + /// up. The undated comment here carries the smallest identity, so folder-name order alone would + /// have put it first. + @Test("A comment with no readable created sorts after its dated siblings") + func undatedCommentsSortLast() throws { + let message = try compose { fixture in + try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("No stamp at all.")) + try fixture.file( + Self.thread(Ident.card2, Self.commentB), + Self.datedComment("Stamped.", created: "2026-07-31T08:00:00Z") + ) + } + + #expect(body(of: message) == [ + "- Comment on 'Ship it'", + "- Comment on 'Fix login'", + ]) + } } // MARK: - Repair diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index 7d05dbf..2600cb1 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -427,6 +427,108 @@ struct FixtureBoardLevelDeletedTests { } } +// MARK: - Valid/optional-keys.kanban + +/// **The optional-key ruling's golden board** (01-storage-format.md § Frontmatter and § Ordering, +/// re-ruled 2026-07-31): below the board root `order` and `schema` are optional, a missing or +/// unusable `order` reads as append-at-end, and a missing `schema` reads as 1. Every shape that used +/// to have its own board under `Malformed/` lives here instead, as a coercion case. +private enum OptionalKeys { + static let rankedLane = "10000000-0000-4000-8000-000000000001" + static let schemalessLane = "40000000-0000-4000-8000-000000000002" + static let orderlessLane = "30000000-0000-4000-8000-000000000003" + + static let rankedCard = "20000000-0000-4000-8000-000000000001" + static let minimumCard = "20000000-0000-4000-8000-000000000002" + static let nullOrderCard = "20000000-0000-4000-8000-000000000003" + static let nonNumericCard = "20000000-0000-4000-8000-000000000004" + static let nonFiniteCard = "20000000-0000-4000-8000-000000000005" +} + +struct FixtureOptionalKeysTests { + + /// The strip: ranked lanes first in ascending order, then the order-less one — and the + /// `schema`-less lane is an ordinary ranked lane, since only its `schema` was absent. + @Test func orderlessLaneSortsAfterEveryRankedOne() throws { + let model = try loadFixture("Valid/optional-keys.kanban").model + #expect(model.lanes.map(\.id.rawValue) == [ + OptionalKeys.rankedLane, OptionalKeys.schemalessLane, OptionalKeys.orderlessLane, + ]) + #expect(model.lanes.map(\.order) == [1024, 2048, 3072]) + } + + /// Four order-less cards behind one ranked one, in folder-name order — the tie-break the ruling + /// states the reading in, and the accepted cost it names ("two order-less siblings sort by UUID + /// rather than by intent until touched"). + @Test func orderlessCardsAppendInFolderNameOrder() throws { + let model = try loadFixture("Valid/optional-keys.kanban").model + let lane = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.rankedLane }) + #expect(lane.cards.map(\.id.rawValue) == [ + OptionalKeys.rankedCard, + OptionalKeys.minimumCard, + OptionalKeys.nullOrderCard, + OptionalKeys.nonNumericCard, + OptionalKeys.nonFiniteCard, + ]) + // `append`'s own arithmetic, which is what makes the reading stampable verbatim. + #expect(lane.cards.map(\.order) == [1024, 2048, 3072, 4096, 5120]) + } + + /// A missing `schema` below the root reads as 1 — at both levels. + @Test func missingSchemaBelowTheRootReadsAsOne() throws { + let model = try loadFixture("Valid/optional-keys.kanban").model + let lane = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.schemalessLane }) + #expect(lane.schema == 1) + + let ranked = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.rankedLane }) + let minimum = try #require(ranked.cards.first { $0.id.rawValue == OptionalKeys.minimumCard }) + #expect(minimum.schema == 1) + #expect(minimum.title == .valid("Minimum Agent Card")) + } + + /// Every reading leaves a coerce-tier trace: field, path, and the text as written — an absent + /// key having none to record (01-storage-format.md § Frontmatter, the family posture). + @Test func everyReadingIsRecordedAsACoercion() throws { + let result = try loadFixture("Valid/optional-keys.kanban") + let byPath = Dictionary( + uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) }) + + func fields(_ path: String) throws -> [CoercedField] { + try #require(byPath[path], "no coercion recorded for \(path)") + } + + #expect(try fields("\(OptionalKeys.orderlessLane)/index.md") == [CoercedField(key: "order", raw: "")]) + #expect(try fields("\(OptionalKeys.schemalessLane)/index.md") == [CoercedField(key: "schema", raw: "")]) + + let lane = OptionalKeys.rankedLane + #expect(try fields("\(lane)/\(OptionalKeys.minimumCard)/index.md") == [ + CoercedField(key: "schema", raw: ""), CoercedField(key: "order", raw: ""), + ]) + #expect(try fields("\(lane)/\(OptionalKeys.nullOrderCard)/index.md") + == [CoercedField(key: "order", raw: "")]) + #expect(try fields("\(lane)/\(OptionalKeys.nonNumericCard)/index.md") + == [CoercedField(key: "order", raw: "banana")]) + #expect(try fields("\(lane)/\(OptionalKeys.nonFiniteCard)/index.md") + == [CoercedField(key: "order", raw: ".nan")]) + + // Coerce-tier means read-side only: nothing here is work, so nothing carries a heal class. + #expect(result.defects.allSatisfy { $0.healClass == nil }) + } + + /// The whole board loads clean — no warnings, no fail-fast, and every file byte-identical after + /// a parse/serialize round-trip: the bytes are preserved verbatim, which is the coerce tier's + /// other half. + @Test func loadsWithoutWarningsAndRoundTrips() throws { + let result = try loadFixture("Valid/optional-keys.kanban") + #expect(result.warnings.isEmpty) + + for file in try allIndexMdFiles(under: fixtureBoard("Valid/optional-keys.kanban")) { + let text = try String(contentsOf: file, encoding: .utf8) + #expect(try FrontmatterDocument.parse(text).serialized() == text) + } + } +} + // MARK: - Malformed/*.kanban — fail-fast cases struct FixtureMalformedTests { @@ -436,6 +538,9 @@ struct FixtureMalformedTests { } } + /// **The board root's own `schema` is still required** (re-ruled 2026-07-31 — the + /// this-really-is-a-board gate). Its below-the-root twin is `FixtureOptionalKeysTests`, where + /// the same absence reads as 1. @Test func missingSchema() { expectFixtureFailure("Malformed/missing-schema.kanban", path: "index.md", reasonDescription: "missingSchema") { $0 == .missingSchema @@ -450,48 +555,6 @@ struct FixtureMalformedTests { } } - @Test func missingOrderOnLane() { - let lane = "10000000-0000-4000-8000-000000000001" - expectFixtureFailure( - "Malformed/missing-order-lane.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder" - ) { - $0 == .missingOrder - } - } - - @Test func missingOrderOnCard() { - let lane = "10000000-0000-4000-8000-000000000001" - let card = "20000000-0000-4000-8000-000000000002" - expectFixtureFailure( - "Malformed/missing-order-card.kanban", path: "\(lane)/\(card)/index.md", reasonDescription: "missingOrder" - ) { - $0 == .missingOrder - } - } - - /// Explicit null reads as missing (01-storage-format.md § Malformed input): `order:` with - /// nothing after it fails the same way a missing key does, not as `.malformedOrder`. - @Test func explicitNullOrderReadsAsMissing() { - let lane = "10000000-0000-4000-8000-000000000001" - expectFixtureFailure( - "Malformed/explicit-null-order.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder" - ) { - $0 == .missingOrder - } - } - - @Test func presentButNonNumericOrder() { - let lane = "10000000-0000-4000-8000-000000000001" - let card = "20000000-0000-4000-8000-000000000002" - expectFixtureFailure( - "Malformed/non-numeric-order.kanban", - path: "\(lane)/\(card)/index.md", - reasonDescription: "malformedOrder(banana)" - ) { - $0 == .malformedOrder(raw: "banana") - } - } - @Test func boardRootMissingIndex() { expectFixtureFailure( "Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex" diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index be5f3cf..817aefe 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -527,8 +527,13 @@ struct FrontmatterStrictFieldTests { } /// NaN has no place in the total order the tie-break and midpoint math assume - /// (01-storage-format.md § Frontmatter, settled): a non-finite reading is the same loud - /// malformed-input rejection as a non-numeric one, never a silently `.valid(Double.nan)`. + /// (01-storage-format.md § Frontmatter, settled): a non-finite reading is `.malformed` exactly + /// like a non-numeric one, never a silently `.valid(Double.nan)`. + /// + /// What that *costs* is the rulebook's, and it changed on 2026-07-31: below the board root both + /// shapes now read as append-at-end, coerce-tier, rather than failing the load + /// (`IntegrityRules.resolvedOrder`, `BoardLoaderOptionalOrderTests`). The document's reading is + /// unchanged, which is the point of the split. @Test func nonFiniteOrderIsMalformedNotValid() throws { #expect(try document("order: .nan").order == .malformed(raw: ".nan")) #expect(try document("order: .inf").order == .malformed(raw: ".inf")) diff --git a/KanbanTests/GitIdentityTests.swift b/KanbanTests/GitIdentityTests.swift index bf7b646..ee3714b 100644 --- a/KanbanTests/GitIdentityTests.swift +++ b/KanbanTests/GitIdentityTests.swift @@ -106,26 +106,57 @@ struct GitConfigFileTests { #expect(neither == derived, "a blank value is not a value") } - @Test("Comments, quoting and subsections are read the way git reads them") + @Test("Comments and quoting are read the way git reads them") func theParseHandlesTheFormatsEdges() { let text = """ # a comment ; another - [user "work"] - \tname = Wrong Section [user] \tname = "Ada # Lovelace" \temail = ada@example.com # trailing comment """ let identity = GitConfigFile.identity(inConfigText: text) - // `[user "work"]` is a subsection but still the `user` section — git reads its keys as - // `user.name` under a subsection name, and this parse deliberately takes the last value it - // meets rather than inventing subsection scoping for a file that has none in practice. #expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content") #expect(identity.email == "ada@example.com", "an unquoted trailing comment is not") } + @Test("Reads take the last plain-section value, and no subsection's") + func readsTakeTheLastPlainSectionValue() { + // **Writes append, reads take the last** (06 ▸ Interaction with external writers, blessed + // 2026-07-31): "the reader — like git itself — takes the last plain-section value, which is + // exactly what an append produces." + let appended = """ + [user] + \tname = Old Ada + \temail = old@example.com + [user] + \tname = New Ada + \temail = new@example.com + """ + #expect(GitConfigFile.identity(inConfigText: appended).name == "New Ada") + #expect(GitConfigFile.identity(inConfigText: appended).email == "new@example.com") + + // A subsection is a *different key* in git's model — `user.work.name`, not `user.name` — so + // it is not an answer to this question however late in the file it sits. Signing the user's + // commits with an identity they filed under a name this app never asked about would be the + // worse error, and 06 says plain-section for exactly that reason. + let subsectioned = """ + [user] + \tname = Ada + \temail = ada@example.com + [user "work"] + \tname = Work Ada + \temail = ada@work.example + """ + #expect(GitConfigFile.identity(inConfigText: subsectioned).name == "Ada") + #expect(GitConfigFile.identity(inConfigText: subsectioned).email == "ada@example.com") + + // A file with *only* a subsection names nobody, and falls through to the derived default. + let onlySubsection = "[user \"work\"]\n\tname = Work Ada\n\temail = ada@work.example\n" + #expect(GitConfigFile.identity(inConfigText: onlySubsection) == (nil, nil)) + } + @Test("A config with no `[user]` section, or no config at all, names nobody") func absentConfigNamesNobody() throws { let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n") diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift index 669b042..892a96e 100644 --- a/KanbanTests/GitUndoTests.swift +++ b/KanbanTests/GitUndoTests.swift @@ -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 diff --git a/KanbanTests/GitignoreRulesTests.swift b/KanbanTests/GitignoreRulesTests.swift new file mode 100644 index 0000000..6f125ee --- /dev/null +++ b/KanbanTests/GitignoreRulesTests.swift @@ -0,0 +1,342 @@ +import Foundation +import Testing +@testable import Kanban + +/// **The board's noise definition, as a parser** (01-storage-format.md § Fractal layout ▸ Rules, +/// ruled 2026-07-31: "`.gitignore` is the noise gate"; 06-history-undo.md ▸ Repository hygiene). +/// +/// `GitignoreRules` is pure — text in, verdicts out — so this suite is a table and nothing else: no +/// filesystem, no board, no store. What it pins is that the app's own matcher is **git's**, because +/// the ruling's whole premise is one definition of noise shared with the committer: "on Pro boards +/// the same file governs the committer, so ignored noise neither relocates nor commits". A matcher +/// that read the file differently from libgit2 would make that one sentence two behaviours. +/// +/// The claims are grouped as `gitignore(5)` states them, in its order, plus the seed the app writes +/// and the three deliberate divergences the type documents. + +/// One assertion, spelled the way the file reads: patterns on the left, a path on the right. +private func ignores(_ file: String, _ path: String, isDirectory: Bool = false) -> Bool { + GitignoreRules(parsing: file).isIgnored(relativePath: path, isDirectory: isDirectory) +} + +// MARK: - What is a pattern at all + +@Suite("Gitignore ▸ the line grammar") +struct GitignoreLineGrammarTests { + + @Test("Blank lines and comments match nothing") + func blanksAndComments() { + let file = """ + # a comment + + #notes.txt + """ + let rules = GitignoreRules(parsing: file) + #expect(rules.isEmpty) + #expect(!rules.isIgnored(relativePath: "notes.txt")) + #expect(!rules.isIgnored(relativePath: "# a comment")) + } + + /// An empty file is 06's own escape hatch — "the escape hatch for wanting no exclusions is an + /// *empty* file, which the app honors" — and it must read as "excludes nothing", never as + /// "excludes everything". + @Test("An empty file excludes nothing, and says so") + func emptyFile() { + #expect(GitignoreRules(parsing: "").isEmpty) + #expect(!ignores("", "notes.txt")) + #expect(!ignores("", "lane/card/notes.txt")) + #expect(!ignores("\n\n\n", "notes.txt")) + } + + /// `#` only comments when it *begins* the line, and `\#` writes a pattern that starts with one. + @Test("A hash is escapable, and only leading hashes comment") + func escapedHash() { + #expect(ignores("\\#notes.txt", "#notes.txt")) + #expect(!ignores("\\#notes.txt", "notes.txt")) + #expect(ignores("notes#1.txt", "notes#1.txt")) + } + + /// "Trailing spaces are ignored unless they are quoted with backslash." + @Test("Trailing spaces are dropped unless escaped") + func trailingSpaces() { + #expect(ignores("notes.txt ", "notes.txt")) + // The escaped one is part of the name — so the bare name no longer matches, and the + // space-suffixed one does. + #expect(ignores("notes.txt\\ ", "notes.txt ")) + #expect(!ignores("notes.txt\\ ", "notes.txt")) + // Only spaces, and only trailing: a tab is part of the pattern (git trims spaces alone). + #expect(ignores("notes.txt\t", "notes.txt\t")) + } + + /// A `.gitignore` hand-edited on Windows must not become a file of patterns nobody can match — + /// git terminates each pattern before the `\r`, and so does this. + @Test("CRLF line endings parse, and a BOM is skipped") + func lineEndingsAndBOM() { + #expect(ignores("*.tmp\r\nbuild/\r\n", "scratch.tmp")) + #expect(ignores("\u{FEFF}*.tmp\n", "scratch.tmp")) + } +} + +// MARK: - Anchoring + +@Suite("Gitignore ▸ anchoring") +struct GitignoreAnchoringTests { + + /// "If there is no separator … the pattern may also match at any level below" — which is the + /// property the seed leans on entirely: one `.DS_Store` line covers every folder in the board. + @Test("A separator-less pattern matches at every depth") + func unanchoredMatchesEverywhere() { + let file = ".DS_Store\n" + #expect(ignores(file, ".DS_Store")) + #expect(ignores(file, "lane/.DS_Store")) + #expect(ignores(file, "lane/card/.DS_Store")) + #expect(!ignores(file, "lane/card/DS_Store")) + #expect(ignores(file, "lane/.DS_Store/inside.txt"), "and a directory by that name takes everything with it") + } + + @Test("A leading slash anchors to the board root") + func leadingSlashAnchors() { + let file = "/notes.txt\n" + #expect(ignores(file, "notes.txt")) + #expect(!ignores(file, "lane/notes.txt")) + #expect(!ignores(file, "lane/card/notes.txt")) + } + + /// "If there is a separator at the beginning or middle (or both) … the pattern is relative to + /// the directory level of the particular `.gitignore` file itself" — which for a board is its + /// root, and the reason the loose-file gate matches the **board-relative** path. + @Test("An interior slash anchors too") + func interiorSlashAnchors() { + let file = "lane/notes.txt\n" + #expect(ignores(file, "lane/notes.txt")) + #expect(!ignores(file, "other/lane/notes.txt")) + #expect(!ignores(file, "notes.txt")) + } + + /// A *trailing* separator is the directory marker and does not anchor: `build/` still means "any + /// folder called build, anywhere". + @Test("A trailing slash does not anchor") + func trailingSlashDoesNotAnchor() { + let file = "build/\n" + #expect(ignores(file, "build", isDirectory: true)) + #expect(ignores(file, "lane/card/build", isDirectory: true)) + #expect(ignores(file, "lane/card/build/output.o")) + } +} + +// MARK: - Directory-only patterns + +@Suite("Gitignore ▸ directory-only patterns") +struct GitignoreDirectoryTests { + + @Test("A trailing slash matches only directories") + func directoryOnly() { + let file = "cache/\n" + #expect(ignores(file, "cache", isDirectory: true)) + #expect(!ignores(file, "cache", isDirectory: false), "a *file* called cache is not what the pattern is about") + // …and everything inside the directory rides along. + #expect(ignores(file, "cache/thing.bin")) + } + + @Test("Without the slash, files and directories both match") + func withoutTheSlash() { + #expect(ignores("cache\n", "cache", isDirectory: false)) + #expect(ignores("cache\n", "cache", isDirectory: true)) + } +} + +// MARK: - Negation and last-match-wins + +@Suite("Gitignore ▸ negation and precedence") +struct GitignoreNegationTests { + + @Test("The last matching pattern decides") + func lastMatchWins() { + #expect(!ignores("*.txt\n!notes.txt\n", "notes.txt")) + #expect(ignores("!notes.txt\n*.txt\n", "notes.txt"), "order is the whole of the rule") + #expect(ignores("*.txt\n!notes.txt\n*.txt\n", "notes.txt")) + #expect(ignores("*.txt\n!notes.txt\n", "other.txt")) + } + + /// "It is not possible to re-include a file if a parent directory of that file is excluded" — + /// git never descends into an ignored directory, so the rule that would have rescued the file is + /// never read at all. + @Test("A negation cannot reach inside an excluded directory") + func negationCannotEscapeAnExcludedParent() { + let file = "build/\n!build/keep.txt\n" + #expect(ignores(file, "build/keep.txt")) + #expect(ignores(file, "build/deep/keep.txt")) + } + + /// A directory the file re-includes is not excluded, so its contents are reachable again. + @Test("A re-included directory lets its contents through") + func reIncludedDirectory() { + let file = "lane\n!lane\n" + #expect(!ignores(file, "lane/card/notes.txt")) + } + + @Test("A leading bang is escapable") + func escapedBang() { + #expect(ignores("\\!important.txt\n", "!important.txt")) + #expect(!ignores("\\!important.txt\n", "important.txt")) + } +} + +// MARK: - Wildcards + +@Suite("Gitignore ▸ wildcards") +struct GitignoreWildcardTests { + + @Test("A star matches any run of characters but never a separator") + func starDoesNotCrossSeparators() { + #expect(ignores("*.tmp\n", "scratch.tmp")) + #expect(ignores("*.tmp\n", "lane/card/scratch.tmp")) + #expect(ignores("lane/*.tmp\n", "lane/scratch.tmp")) + #expect(!ignores("lane/*.tmp\n", "lane/card/scratch.tmp"), "one star, one segment") + #expect(ignores("*\n", "anything")) + } + + @Test("A star matches nothing at all, at either end") + func starMatchesEmpty() { + #expect(ignores("*.tmp\n", ".tmp")) + #expect(ignores("notes*\n", "notes")) + #expect(ignores("*notes*\n", "notes")) + } + + @Test("A question mark is exactly one character, and never a separator") + func questionMark() { + #expect(ignores("shot?.png\n", "shot1.png")) + #expect(!ignores("shot?.png\n", "shot.png")) + #expect(!ignores("shot?.png\n", "shot10.png")) + #expect(!ignores("a?b\n", "a/b")) + } + + @Test("Character classes: sets, ranges, negation, and a literal bracket") + func characterClasses() { + #expect(ignores("shot[0-9].png\n", "shot7.png")) + #expect(!ignores("shot[0-9].png\n", "shotX.png")) + #expect(ignores("shot[abc].png\n", "shotb.png")) + #expect(!ignores("shot[!abc].png\n", "shotb.png")) + #expect(ignores("shot[!abc].png\n", "shotz.png")) + #expect(ignores("shot[^abc].png\n", "shotz.png"), "^ negates too") + // A `]` first in the group is a literal member, not the terminator. + #expect(ignores("weird[]].txt\n", "weird].txt")) + // An unterminated group is a literal bracket — the reading that cannot lose a character. + #expect(ignores("draft[1.txt\n", "draft[1.txt")) + } + + @Test("Escapes make a wildcard literal") + func escapedWildcards() { + #expect(ignores("star\\*.txt\n", "star*.txt")) + #expect(!ignores("star\\*.txt\n", "starry.txt")) + } +} + +// MARK: - Globstar + +@Suite("Gitignore ▸ ** segments") +struct GitignoreGlobstarTests { + + @Test("A leading **/ matches at any depth") + func leadingGlobstar() { + let file = "**/notes.txt\n" + #expect(ignores(file, "notes.txt")) + #expect(ignores(file, "lane/notes.txt")) + #expect(ignores(file, "lane/card/notes.txt")) + } + + /// "A trailing `/**` matches everything inside" — everything *inside*, so the directory itself is + /// not what this pattern is about. + @Test("A trailing /** matches everything inside, not the folder itself") + func trailingGlobstar() { + let file = "lane/**\n" + #expect(ignores(file, "lane/card")) + #expect(ignores(file, "lane/card/notes.txt")) + #expect(!ignores(file, "lane", isDirectory: true)) + } + + /// "`a/**/b` matches `a/b`, `a/x/b`, `a/x/y/b`" — zero or more segments, verbatim. + @Test("A middle /**/ spans zero or more directories") + func middleGlobstar() { + let file = "a/**/b\n" + #expect(ignores(file, "a/b")) + #expect(ignores(file, "a/x/b")) + #expect(ignores(file, "a/x/y/b")) + #expect(!ignores(file, "b")) + #expect(!ignores(file, "x/a/b")) + } + + /// "Other consecutive asterisks are considered regular asterisks" — inside a segment, `**` is + /// just `*`, so it still cannot cross a separator. + @Test("Asterisks inside a segment are ordinary stars") + func consecutiveStarsInsideASegment() { + #expect(ignores("a**b\n", "axxb")) + #expect(!ignores("a**b\n", "a/x/b")) + } +} + +// MARK: - The seed + +@Suite("Gitignore ▸ the seed the app writes") +struct GitignoreSeedSemanticsTests { + + private let seed = GitignoreRules(parsing: BoardWriter.gitignoreSeed) + + @Test("The seed is exactly the two lines the ruling names") + func theSeedText() { + // 06-history-undo.md ▸ Repository hygiene: "`.DS_Store` plus the writer's temp pattern + // (`.*.lanework-*`)". Files the app creates end with LF. + #expect(BoardWriter.gitignoreSeed == ".DS_Store\n.*.lanework-*\n") + } + + @Test("It covers the Finder's litter at every level") + func finderLitter() { + #expect(seed.isIgnored(relativePath: ".DS_Store")) + #expect(seed.isIgnored(relativePath: "\(Ident.lane1)/.DS_Store")) + #expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/.DS_Store")) + } + + /// The second line and `BoardWriter.atomicReplace`'s temp name are one fact spelled twice, so + /// this asserts against a name the Writer's own rule produces rather than a hand-written one. + @Test("It covers a crashed write's residue") + func writerTempResidue() { + let residue = ".index.md.lanework-\(UUID().uuidString)" + #expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/\(residue)")) + #expect(seed.isIgnored(relativePath: ".\(IntegrityRules.gitignoreFileName).lanework-\(UUID().uuidString)")) + } + + @Test("And nothing else — a card's real files are not noise") + func nothingElse() { + #expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/notes.txt")) + #expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/index.md")) + #expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/DS_Store.txt")) + #expect(!seed.isIgnored(relativePath: "CLAUDE.md")) + } +} + +// MARK: - The documented divergences + +@Suite("Gitignore ▸ the deliberate divergences") +struct GitignoreDivergenceTests { + + /// Matching is case-sensitive, always: `core.ignorecase` is a repository setting on a file this + /// app reads on boards that have no repository, and folding a *pattern* would silently widen + /// what the user wrote. + @Test("Patterns are case-sensitive") + func caseSensitive() { + #expect(ignores("*.tmp\n", "scratch.tmp")) + #expect(!ignores("*.tmp\n", "scratch.TMP")) + #expect(!ignores(".DS_Store\n", ".ds_store")) + } + + /// POSIX bracket expressions are not a grammar this matcher has: `[[:digit:]]` reads as the + /// ordinary group `[` `[:digt]` — the characters between the brackets — followed by a literal + /// `]`, so it matches `shot:].png` rather than `shot7.png`. Nothing realistic in a board's noise + /// file writes one, and a second character-class grammar to hold them would be exactly the + /// over-engineering the type exists to avoid. + @Test("POSIX bracket expressions are read as ordinary classes") + func posixClasses() { + #expect(ignores("shot[[:digit:]].png\n", "shot:].png")) + #expect(!ignores("shot[[:digit:]].png\n", "shot7.png")) + } +} diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 04508c9..066eacc 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -295,6 +295,65 @@ struct HistoryStoreAddGitTests { #expect(reason.operation == "Adding git to this board") #expect(!reason.message.isEmpty) } + + @Test("Create re-runs full detection and refuses a board that became repo-nested") + func createRefusesAStaleModeNone() async throws { + // **The hardening** (06 ▸ Rules ▸ Detection, ruled 2026-07-31): "add-git's create re-runs full + // detection and refuses unless it reads clean none, so the forbidden nested init is impossible + // even on a raced or stale read." + let outer = try WriterFixture() + defer { outer.tearDown() } + let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) + + // Composed while the enclosing folder is still a plain one: the store's mode is `none`, and + // that is the reading that goes stale. + let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) + #expect(git.mode == .none) + + // A terminal `git init` one level up, after the detection the store is holding. + try plantGitDirectory(in: outer) + + #expect(await git.addGit() == false, "a root-only check would have let this through") + #expect( + !FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path), + "no nested repository, ever" + ) + #expect(git.mode == .none, "a refused add-git changes nothing, mode included") + } + + @Test("A failure answers at the form when it is up, and at the banner when it is not") + @MainActor + func aFailureAnswersAtTheFormOrTheBanner() async throws { + // **Form-anchored operations answer at the form first** (06 ▸ Interaction with external + // writers, ruled 2026-07-31) — "inline is the primary surface, never a silence trap". + let fixture = try makeBoard() + defer { fixture.tearDown() } + + // Mode is read once, at composition — so a store composed before a `.git` appeared still says + // `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the + // question is where the answer lands. + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + try plantGitDirectory(in: fixture) + var banners: [String] = [] + git.reportFailure = { banners.append($0.message) } + + // The form is up: inline, and nothing on the strip. + git.noteFormVisible(true) + #expect(await git.addGit() == false) + #expect(git.lastFailure != nil) + #expect(banners.isEmpty, "the user is looking at the form the answer belongs in") + + // Dismissing it dismisses the stale error. + git.noteFormVisible(false) + #expect(git.lastFailure == nil) + + // Asked again with no form on screen, the answer takes the banner instead of nobody. + #expect(await git.addGit() == false) + #expect(git.lastFailure == nil) + #expect(banners.count == 1) + } } // MARK: - The loader's history ranker diff --git a/KanbanTests/IntegrityRulesTests.swift b/KanbanTests/IntegrityRulesTests.swift index c5f189b..c91b111 100644 --- a/KanbanTests/IntegrityRulesTests.swift +++ b/KanbanTests/IntegrityRulesTests.swift @@ -172,14 +172,6 @@ struct IntegrityValidationTests { private func bytes(_ text: String) -> Data { Data(text.utf8) } - /// The per-kind field table: `order` is required on lanes and cards, **never** on the board. - @Test("Order is required per kind") - func orderIsRequiredPerKind() { - #expect(!IntegrityRules.requiresOrder(.board)) - #expect(IntegrityRules.requiresOrder(.lane)) - #expect(IntegrityRules.requiresOrder(.card)) - } - @Test("A board index validates without an order") func boardValidatesWithoutOrder() throws { let document = try IntegrityRules.validateIndex( @@ -191,33 +183,71 @@ struct IntegrityValidationTests { #expect(document.title == .valid("Board")) } - @Test("A lane or card index without an order is refused") - func laneAndCardRequireOrder() { + /// **`order` is optional at every kind** (01-storage-format.md § Ordering, re-ruled 2026-07-31): + /// a lane or card without one reads as append-at-end rather than being refused, so the validator + /// has nothing to say about it. + @Test("A lane or card index without an order validates") + func laneAndCardDoNotRequireOrder() throws { for kind in [IntegrityRules.ObjectKind.lane, .card] { - #expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) { - try IntegrityRules.validateIndex( - bytes("---\nschema: 1\n---\nbody\n"), - path: "index.md", - kind: kind, - supportedSchema: 1 - ) - } + let document = try IntegrityRules.validateIndex( + bytes("---\nschema: 1\n---\nbody\n"), + path: "index.md", + kind: kind, + supportedSchema: 1 + ) + #expect(document.order.isMissing) + } + } + + /// **The root's `schema` is required; below it, absence reads as 1** — the one per-kind + /// difference the validator still draws. + @Test("Schema is required at the board and optional below it") + func schemaIsRequiredAtTheRootOnly() throws { + let schemaless = bytes("---\ntitle: No Schema\n---\nbody\n") + #expect(throws: BoardLoadError(path: "index.md", reason: .missingSchema)) { + try IntegrityRules.validateIndex( + schemaless, path: "index.md", kind: .board, supportedSchema: 1) + } + for kind in [IntegrityRules.ObjectKind.lane, .card, .comment] { + let document = try IntegrityRules.validateIndex( + schemaless, path: "index.md", kind: kind, supportedSchema: 1) + #expect(document.schema.isMissing) } } /// The card window's gate is this rule at `kind: .card` — one function, not a copy. @Test("validateCardIndex is validateIndex at card") - func cardValidatorIsTheGeneralOne() { - let missingOrder = bytes("---\nschema: 1\n---\nbody\n") - #expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) { - try BoardLoader.validateCardIndex(missingOrder, path: "index.md") - } + func cardValidatorIsTheGeneralOne() throws { + // No `order`, no `schema` — the minimum agent card, and a legal raw-source Apply since + // 2026-07-31. + let minimum = try BoardLoader.validateCardIndex(bytes("---\ntitle: Minimum\n---\nbody\n"), path: "index.md") + #expect(minimum.title == .valid("Minimum")) + let newer = bytes("---\nschema: 99\norder: 1\n---\n") #expect(throws: BoardLoadError(path: "index.md", reason: .schemaNewerThanApp(found: 99))) { try BoardLoader.validateCardIndex(newer, path: "index.md") } } + /// The rulebook's own readings, without a filesystem in the way. + @Test("The order reading is stated over usability") + func orderReadingIsStatedOverUsability() throws { + func reading(_ frontmatter: String) throws -> (order: Double?, coerced: CoercedField?) { + IntegrityRules.resolvedOrder(in: try FrontmatterDocument.parse("---\n\(frontmatter)---\n")) + } + #expect(try reading("order: 1024\n").order == 1024) + #expect(try reading("order: 1024\n").coerced == nil) + + for (frontmatter, raw) in [ + ("schema: 1\n", ""), ("order:\n", ""), ("order: null\n", "null"), + ("order: banana\n", "banana"), ("order: .nan\n", ".nan"), ("order: .inf\n", ".inf"), + ] { + let read = try reading(frontmatter) + #expect(read.order == nil, "\(frontmatter) should be unusable") + #expect(read.coerced == CoercedField(key: "order", raw: raw), "\(frontmatter)") + } + } + /// The refuse-writes verdict's rule, named in the vocabulary rather than left as a property one /// call site happens to read. @Test("The uneditable shape is the document's, named here") @@ -275,6 +305,43 @@ struct IntegrityOnTouchTests { #expect(IntegrityRules.healOnTouch(&document, kind: nil).isEmpty) #expect(document.kind == .missing) } + + /// **The rank materializes on touch** (01-storage-format.md § Ordering, re-ruled 2026-07-31): + /// the value written is the append-at-end reading the board was already rendering, so nothing + /// moves when the stamp lands. + @Test("A missing order is stamped with the rank it read as") + func missingOrderIsStamped() throws { + var document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: Minimum\n---\nbody\n") + let heals = IntegrityRules.healOnTouch(&document, kind: .card, rank: 3072) + #expect(heals == [.kindBackfilled(.card), .rankStamped(3072)]) + #expect(document.order == .valid(3072)) + #expect(document.keys == ["schema", "title", "kind", "order"]) + #expect(document.body == "body\n") + } + + /// **Unlike `kind`, an unusable *present* value is replaced**: a rank has to be a number for the + /// midpoint math to mean anything, so `banana` and `.nan` heal exactly like an absent key. + @Test("An unusable order is stamped too") + func unusableOrderIsStamped() throws { + for text in ["order: banana", "order: .nan", "order:"] { + var document = try FrontmatterDocument.parse("---\nschema: 1\n\(text)\nkind: card\n---\n") + #expect(IntegrityRules.healOnTouch(&document, kind: .card, rank: 2048) == [.rankStamped(2048)]) + #expect(document.order == .valid(2048), "\(text)") + } + } + + /// A usable rank is never rewritten, and a caller with no rank to offer stamps nothing — the + /// board root and a comment, which have no ladder to sit in. + @Test("A present rank, and a nil rank, stamp nothing") + func presentOrAbsentRankStampsNothing() throws { + var ranked = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\nkind: card\n---\n") + #expect(IntegrityRules.healOnTouch(&ranked, kind: .card, rank: 9999).isEmpty) + #expect(ranked.order == .valid(1024)) + + var rankless = try FrontmatterDocument.parse("---\nschema: 1\nkind: board\n---\n") + #expect(IntegrityRules.healOnTouch(&rankless, kind: .board, rank: nil).isEmpty) + #expect(rankless.order == .missing) + } } // MARK: - The defect vocabulary diff --git a/KanbanTests/LooseFileRelocationTests.swift b/KanbanTests/LooseFileRelocationTests.swift index 8139853..180ff82 100644 --- a/KanbanTests/LooseFileRelocationTests.swift +++ b/KanbanTests/LooseFileRelocationTests.swift @@ -15,6 +15,12 @@ import Testing /// lock, and never hot-looping on a failure. /// 4. **A paste normalizes at the boundary** — the pasted card lands already tidy. /// +/// Since 2026-07-31 there is a gate in front of all four: **the board-root `.gitignore` is the noise +/// definition**, and a loose file matching it is not work at all (§ Rules — "skipped, preserved +/// verbatim, logged, never relocated, never announced"). That is section 1b, between the detection +/// and the write, because it is a property of what counts as a defect rather than of what is done +/// about one. +/// /// Like every other write suite here these read back through the loader or through raw bytes, never /// through a snapshot the store handed out: the claims are about the files. `WriterFixture`, `Ident` /// and `Item` come from `WriterTestSupport.swift`; `FakePasteboard` and `ClipboardHarness` from @@ -24,15 +30,17 @@ import Testing /// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`. /// -/// It carries a **current agent guide**, which is what any board the app has opened once looks like -/// (08-agent-integration.md ▸ The agent guide). Without it the store's own guide refresh — which -/// runs on every successful reload, beside this file's relocation — would write a `CLAUDE.md` on -/// the first reload and open a bracket of its own, and the bracket counts below would stop being -/// claims about the relocation. -private func makeCardBoard() throws -> WriterFixture { +/// It carries a **current agent guide** and the **seeded `.gitignore`**, which is what any board the +/// app has opened once looks like (08-agent-integration.md ▸ The agent guide; 06-history-undo.md ▸ +/// Repository hygiene). Without them the store's own scheduled heals — the guide refresh and the +/// seed, which run on every successful reload beside this file's relocation — would write those two +/// files on the first reload and open brackets of their own, and the bracket counts below would stop +/// being claims about the relocation. +private func makeCardBoard(gitignore: String = BoardWriter.gitignoreSeed) throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + try fixture.file(IntegrityRules.gitignoreFileName, Data(gitignore.utf8)) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) return fixture @@ -221,6 +229,169 @@ struct LooseFileDetectionTests { } } +// MARK: - 1b. The noise gate (the board-root .gitignore) + +/// **`.gitignore` is the noise gate** (01-storage-format.md § Fractal layout ▸ Rules, ruled +/// 2026-07-31): "a file matching the board-root `.gitignore` — standard gitignore pattern semantics +/// against the board-relative path; nested `.gitignore` files are ordinary strays the heal never +/// consults — keeps the ordinary stray posture: skipped, preserved verbatim, logged, never +/// relocated, never announced. The exclusion list is exactly that file, nothing hardcoded". +/// +/// The pattern *semantics* are `GitignoreRulesTests`'; what this suite pins is the wiring — which +/// file is read, which path it is matched against, and that a match makes the file a stray rather +/// than work. +@Suite("Loose files ▸ the .gitignore noise gate") +struct LooseFileNoiseGateTests { + + @Test("A file matching the board's .gitignore is not a loose file") + func matchedFileIsNotWork() throws { + let fixture = try makeCardBoard(gitignore: "*.tmp\n") + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8)) + + #expect(try looseFiles(in: fixture).isEmpty) + } + + /// The seed's own first line, doing its job on the file it was written for. `.DS_Store` is also + /// hidden — and hidden entries are excluded for a structural reason of their own (a relocated + /// hidden file would land where `attachmentNames` can never list it) — so this is the belt and + /// the braces, which is what the ruling asks for: the seed is why it is *noise*. + @Test("A .DS_Store beside a card's index.md is neither relocated nor announced") + @MainActor + func finderLitterIsSilent() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8)) + let store = try BoardStore(rootURL: fixture.root) + + store.relocateLooseCardFiles() + + #expect(fixture.exists("\(cardPath)/.DS_Store"), "preserved verbatim, exactly where it was") + #expect(!fixture.exists("\(cardPath)/attachments")) + #expect(store.banners.losses.isEmpty, "never announced") + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A matched custom pattern is skipped, preserved and never announced") + @MainActor + func matchedCustomPatternIsSilent() throws { + let fixture = try makeCardBoard(gitignore: "*.tmp\nbuild/\n") + defer { fixture.tearDown() } + let noise = try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8)) + let before = try stat(noise) + let store = try BoardStore(rootURL: fixture.root) + + store.relocateLooseCardFiles() + + let after = try stat(noise) + #expect(after.bytes == before.bytes) + #expect(after.modified == before.modified, "not opened, not moved, not touched") + #expect(!fixture.exists("\(cardPath)/attachments")) + #expect(store.banners.losses.isEmpty) + } + + @Test("An unmatched loose file still relocates, with its notice") + @MainActor + func unmatchedFileStillRelocates() throws { + let fixture = try makeCardBoard(gitignore: "*.tmp\n") + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + let store = try BoardStore(rootURL: fixture.root) + + store.relocateLooseCardFiles() + + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) + #expect(fixture.exists("\(cardPath)/scratch.tmp")) + // One file moved, so one file is named — the noise is not in the sentence either. + #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) + } + + /// 06's escape hatch, working: "the escape hatch for wanting no exclusions is an *empty* file". + @Test("An empty .gitignore excludes nothing — everything loose relocates") + @MainActor + func emptyFileExcludesNothing() throws { + let fixture = try makeCardBoard(gitignore: "") + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + let store = try BoardStore(rootURL: fixture.root) + + store.relocateLooseCardFiles() + + #expect(try fixture.entryNames("\(cardPath)/attachments") == ["notes.txt", "scratch.tmp"]) + #expect(store.banners.losses.map(\.message) == ["Moved 2 files into attachments — 'Fix login'"]) + } + + /// A board that has never been opened by this version has no gate at all, and that reads as "no + /// exclusions" — the pre-ruling behaviour, and the same answer the empty file gives. + @Test("A board with no .gitignore excludes nothing") + func noFileExcludesNothing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + + #expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]]) + } + + /// **The board-relative path is what patterns match**, which is what makes an anchored pattern + /// mean the board root rather than every card in the board. + @Test("Patterns match the board-relative path, so anchoring means the board root") + func anchoredPatternsMeanTheBoardRoot() throws { + let fixture = try makeCardBoard(gitignore: "/notes.txt\n") + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + + // The pattern is about `/notes.txt`; the card's file is a different path entirely. + #expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]]) + + // The same file, named the way the card actually sits on disk, is excluded. + let anchored = try makeCardBoard(gitignore: "/\(cardPath)/notes.txt\n") + defer { anchored.tearDown() } + try anchored.file("\(cardPath)/notes.txt", Data("notes".utf8)) + #expect(try looseFiles(in: anchored).isEmpty) + } + + /// "Nested `.gitignore` files are ordinary strays the heal never consults." One inside a card is + /// itself a hidden entry, so it is not even relocatable; one inside a *lane* is a lane-level + /// stray, and neither has any say over the card beside it. + @Test("Nested .gitignore files are never consulted") + func nestedFilesAreOrdinaryStrays() throws { + let fixture = try makeCardBoard(gitignore: "") + defer { fixture.tearDown() } + try fixture.file("\(Ident.lane1)/\(IntegrityRules.gitignoreFileName)", Data("*.txt\n".utf8)) + try fixture.file("\(cardPath)/\(IntegrityRules.gitignoreFileName)", Data("*.txt\n".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + + #expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]]) + #expect(fixture.exists("\(Ident.lane1)/\(IntegrityRules.gitignoreFileName)")) + #expect(fixture.exists("\(cardPath)/\(IntegrityRules.gitignoreFileName)")) + } + + /// The import boundary reads the same file: a paste that swept a file the very next walk would + /// have left alone would be the app disagreeing with itself one gesture apart. + @Test("The import-boundary normalization obeys the same gate") + func normalizationObeysTheGate() throws { + let fixture = try makeCardBoard(gitignore: "*.tmp\n") + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + + let moved = try BoardWriter.normalizeLooseFiles(inCard: cardFolder(in: fixture)) + + #expect(moved.map(\.fileName) == ["notes.txt"]) + #expect(fixture.exists("\(cardPath)/scratch.tmp")) + + // …and the lane-level face, which reads the file once for the whole lane. + try fixture.file("\(cardPath)/second.tmp", Data("more noise".utf8)) + #expect(try BoardWriter.normalizeLooseFiles(inLane: fixture.url(Ident.lane1)).isEmpty) + #expect(fixture.exists("\(cardPath)/second.tmp")) + } +} + // MARK: - 2. The write (BoardWriter) @Suite("Loose files ▸ the relocation write") diff --git a/KanbanTests/RawSourceTests.swift b/KanbanTests/RawSourceTests.swift index c7f11c0..99e9acf 100644 --- a/KanbanTests/RawSourceTests.swift +++ b/KanbanTests/RawSourceTests.swift @@ -97,16 +97,23 @@ struct RawSourceValidationTests { #expect(document.value(for: "sphere") == .string("work")) } - @Test("The two required fields are required, with the loader's own words") - func schemaAndOrderAreRequired() { - let noSchema = validationFailure("---\ntitle: x\norder: 1\n---\nbody\n") - #expect(noSchema?.reason == .missingSchema) + /// **A card's `schema` and `order` are both optional** (01-storage-format.md § Frontmatter and + /// § Ordering, re-ruled 2026-07-31) — the outlet's gate is the loader's rule, so it moved with + /// it: a card applied without either lands at its lane's bottom, read as schema 1, and gains a + /// real rank on its next touch. + @Test("The optional fields are optional, with the loader's own rule") + func schemaAndOrderAreOptional() throws { + for text in ["---\ntitle: x\norder: 1\n---\nbody\n", "---\nschema: 1\ntitle: x\n---\nbody\n", "---\ntitle: x\n---\nbody\n"] { + #expect(throws: Never.self) { + try BoardLoader.validateCardIndex(Data(text.utf8), path: "index.md") + } + } - let noOrder = validationFailure("---\nschema: 1\ntitle: x\n---\nbody\n") - #expect(noOrder?.reason == .missingOrder) + // What still refuses: a value that is there and unreadable, and a card from a newer app — + // which this one has no honest way to rewrite. + let malformed = validationFailure("---\nschema: one\norder: 1\n---\nbody\n") + #expect(malformed?.reason == .malformedSchema(raw: "one")) - // The same fail-fast rule the loader applies at load: a card from a newer app is not - // something this one may rewrite. let future = validationFailure("---\nschema: 99\norder: 1\n---\nbody\n") #expect(future?.reason == .schemaNewerThanApp(found: 99)) } @@ -409,10 +416,10 @@ struct RawSourceStoreTests { let outcome = store.applyCardSource( inCard: ItemID(rawValue: Ident.card1), - text: "---\ntitle: no schema here\norder: 1\n---\nbody\n" + text: "---\nschema: unreadable\norder: 1\n---\nbody\n" ) - #expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .missingSchema))) + #expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .malformedSchema(raw: "unreadable")))) #expect(try fixture.indexData(cardPath) == before) // The alert is the surfacing for this one — a banner as well would say the same thing twice, // and a write that never started is not a failed write. diff --git a/KanbanTests/RepositoryHygieneTests.swift b/KanbanTests/RepositoryHygieneTests.swift index ecf784d..8e5733d 100644 --- a/KanbanTests/RepositoryHygieneTests.swift +++ b/KanbanTests/RepositoryHygieneTests.swift @@ -4,9 +4,10 @@ import Testing import libgit2 @testable import Kanban -/// **Repository hygiene** (06-history-undo.md ▸ Repository hygiene) — the two behaviours that keep a -/// git board's `.git` sane without ever rewriting anything: the `.gitignore` seeded once at init, and -/// the periodic repack that packs loose objects and touches nothing else. +/// **Repository hygiene** (06-history-undo.md ▸ Repository hygiene) — the behaviours that keep a +/// board's noise out of the way without ever rewriting anything: the `.gitignore` **every board** +/// carries (re-ruled 2026-07-31 — the file outgrew git, so it is seeded at creation and healed in at +/// open, git or not), and the periodic repack that packs loose objects and touches nothing else. /// /// Every repository here is a **real** one, made by the app's own add-git through the bundled /// libgit2, and every assertion is read off the filesystem or out of the object database rather than @@ -154,11 +155,16 @@ private func historyWalk(at boardRoot: URL) throws -> [String] { // MARK: - .gitignore seeding +/// **The add-git half.** Since 2026-07-31 the seed belongs to the *board* rather than to git (the +/// suite below this one), and what survives here is the last-chance check in front of the initial +/// commit: whatever else happened, the tree that becomes "Initial board state" carries a +/// `.gitignore`, because a `.DS_Store` that enters history can never be got out again (06 ▸ Deleting +/// never forgets). @MainActor @Suite("Repository hygiene ▸ the seeded .gitignore") struct GitignoreSeedTests { - @Test("Add-git seeds a .gitignore containing .DS_Store, inside the initial commit") + @Test("Add-git guarantees a .gitignore inside the initial commit") func addGitSeedsTheIgnoreFile() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -166,9 +172,9 @@ struct GitignoreSeedTests { let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) - // The file, and the whole of the file: one line, because one line is the rule - // (06 ▸ Repository hygiene: "a minimal `.gitignore` (`.DS_Store`)"). - #expect(try fixture.data(".gitignore") == Data(".DS_Store\n".utf8)) + // The file, and the whole of the file — the one seed text, shared with board creation and + // the open-time heal (06 ▸ Repository hygiene: "`.DS_Store` plus the writer's temp pattern"). + #expect(try fixture.data(".gitignore") == Data(BoardWriter.gitignoreSeed.utf8)) // **In "Initial board state", not after it.** Seeding after the commit would put the app's // own file into the board's first *foreign* commit; seeding before makes it part of the @@ -236,7 +242,34 @@ struct GitignoreSeedTests { #expect(try snapshot(fixture.root, ".gitignore") == before) } - @Test("Adoption seeds nothing — an adopted repository is somebody else's init") + /// **The second consumer of the one noise definition** (01-storage-format.md § Fractal layout ▸ + /// Rules: "On Pro boards the same file governs the committer, so ignored noise neither relocates + /// nor commits — one definition of noise, two consumers"). The committer's own condition is + /// `changedPaths`, which stages through libgit2 with ignores respected; this pins that the file + /// the loose-file gate reads is the file that decides what commits. + @Test("The committer obeys the same file — ignored noise never becomes a changed path") + func theCommitterObeysTheSameFile() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(await git.addGit()) + + // The user fine-tunes their own noise definition, which is exactly what the file is for. + try fixture.file(".gitignore", Data((BoardWriter.gitignoreSeed + "*.tmp\n").utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.tmp", Data("noise".utf8)) + try fixture.file("\(Ident.lane1)/notes.txt", Data("a real stray".utf8)) + try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x42])) + + let changed = GitCommitOperation.changedPaths(at: fixture.root).map(\.path) + #expect(!changed.contains { $0.hasSuffix("scratch.tmp") }) + #expect(!changed.contains { $0.hasSuffix(".DS_Store") }) + #expect(changed.contains { $0.hasSuffix("notes.txt") }, "and an ordinary stray still commits") + } + + /// Composing history over somebody else's repository writes nothing at all — adoption is not an + /// init, and no *git* path seeds. (The board's own heal is what gives such a board its + /// `.gitignore`, at open, and it is exercised in the suite below.) + @Test("Adoption writes nothing — an adopted repository is somebody else's init") func adoptionSeedsNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -245,11 +278,14 @@ struct GitignoreSeedTests { let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(git.mode == .git) - #expect(!fixture.exists(".gitignore"), "the seed belongs to the app's own init and nowhere else") + #expect(!fixture.exists(".gitignore"), "composing history is not a write") } - @Test("A repo-nested board gets no seed, because it gets no app-managed git") - func repoNestedBoardsGetNothing() async throws { + /// A repo-nested board gets no *git* of the app's, so no git path can seed it — and the + /// enclosing repository is never written into either. What such a board does get is the ordinary + /// board-level seed at open (06's "Repo-nested boards are seeded too"), which is the suite below. + @Test("The git paths never touch a repo-nested board, or its enclosing repo") + func repoNestedBoardsGetNothingFromGit() async throws { let outer = try WriterFixture() defer { outer.tearDown() } try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) @@ -266,6 +302,178 @@ struct GitignoreSeedTests { } } +// MARK: - The .gitignore every board carries + +/// **"`.gitignore` seeded on every board, never touched after"** (06-history-undo.md ▸ Repository +/// hygiene, re-ruled 2026-07-31 — "the file outgrew git: it is the one noise definition the +/// loose-file relocation heal obeys … so every board carries it, git or not"). +/// +/// Three claims, and they are the whole ruling: **creation writes it**, **a board missing it gains +/// it by scheduled heal at open**, and **the app never edits an existing one** — an empty file +/// included, which is the ruling's own escape hatch. The gate it feeds is +/// `LooseFileRelocationTests` ▸ the noise gate; the pattern semantics are `GitignoreRulesTests`. +@MainActor +@Suite("Repository hygiene ▸ the .gitignore every board carries") +struct BoardGitignoreSeedTests { + + private func seedURL(in fixture: WriterFixture) -> URL { + fixture.root.appendingPathComponent(IntegrityRules.gitignoreFileName) + } + + private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let modified = attributes[.modificationDate] as? Date else { + throw NSError(domain: "BoardGitignoreSeedTests", code: 1) + } + return (try Data(contentsOf: url), modified) + } + + @Test("Board creation writes the seed beside index.md") + func creationSeeds() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("New Board.kanban") + + try BoardWriter.createBoard(at: root, title: "New Board") + + #expect(try Data(contentsOf: root.appendingPathComponent(IntegrityRules.gitignoreFileName)) + == Data(BoardWriter.gitignoreSeed.utf8)) + } + + @Test("A board missing the file gains it at open, silently") + func healSeedsAtOpen() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + #expect(!fixture.exists(IntegrityRules.gitignoreFileName)) + let store = try BoardStore(rootURL: fixture.root) + + store.runScheduledHeals() + + #expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8)) + // A courtesy file the user did not create and may not know exists — the guide's posture. + #expect(store.banners.losses.isEmpty) + #expect(store.banners.oneShots.isEmpty) + } + + /// The heal's memo, doing its two jobs: a picture already acted on is not acted on again (no + /// second write), and a picture that comes *back* — a foreign deletion — heals again, because the + /// memo was cleared on success. + @Test("Seeding twice writes once, and a deleted file comes back") + func memoIsArmedAndCleared() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + let store = try BoardStore(rootURL: fixture.root) + + store.seedGitignore() + let first = try stat(seedURL(in: fixture)) + #expect(store.heals.memo(for: .missingGitignore) == nil, "cleared on success") + + store.seedGitignore() + #expect(try stat(seedURL(in: fixture)) == first, "not rewritten — not even opened") + + // What a foreign deletion looks like: the picture "missing" is restored, and a standing memo + // would have made that deletion the one thing this could not heal. + try FileManager.default.removeItem(at: seedURL(in: fixture)) + store.seedGitignore() + #expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8)) + } + + @Test("An existing .gitignore is left byte-for-byte alone, mtime included") + func existingFileIsNeverRewritten() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + let theirs = Data("# mine\nbuild/\n*.tmp\n".utf8) + try fixture.file(IntegrityRules.gitignoreFileName, theirs) + let before = try stat(seedURL(in: fixture)) + let store = try BoardStore(rootURL: fixture.root) + + store.runScheduledHeals() + + #expect(try fixture.data(IntegrityRules.gitignoreFileName) == theirs) + #expect(try stat(seedURL(in: fixture)) == before, "never merged, never appended to, never opened") + } + + /// "The escape hatch for wanting no exclusions is an *empty* file, which the app honors and never + /// rewrites" — the one case where re-seeding would look most reasonable and is most wrong. + @Test("An empty .gitignore is honored and never rewritten") + func emptyFileIsHonored() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + try fixture.file(IntegrityRules.gitignoreFileName, Data()) + let before = try stat(seedURL(in: fixture)) + let store = try BoardStore(rootURL: fixture.root) + + store.runScheduledHeals() + store.runScheduledHeals() + + #expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data()) + #expect(try stat(seedURL(in: fixture)) == before) + } + + /// "Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file + /// serves the heal there, not any app-managed git" — so there is no repo-detection gate on this + /// heal, and the enclosing repository is still never written into. + @Test("A repo-nested board is seeded like any other") + func repoNestedBoardsAreSeeded() throws { + let outer = try WriterFixture() + defer { outer.tearDown() } + try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) + let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) + try Data(AgentGuide.content.utf8).write(to: boardRoot.appendingPathComponent(AgentGuide.filename)) + + let store = try BoardStore(rootURL: boardRoot) + store.runScheduledHeals() + + #expect(try Data(contentsOf: boardRoot.appendingPathComponent(IntegrityRules.gitignoreFileName)) + == Data(BoardWriter.gitignoreSeed.utf8)) + #expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(IntegrityRules.gitignoreFileName).path)) + } + + /// **The claimed name that does not displace** (`IntegrityRules.claimedRootNames`): a wrong-kind + /// node wearing `.gitignore` is left exactly where it is, because a board with no readable noise + /// definition simply excludes nothing — nothing breaks while the name is held, so nothing of the + /// user's is moved to buy a courtesy file. + @Test("A folder wearing the name is left alone, and nothing is written through it") + func squatterIsLeftAlone() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + try fixture.file("\(IntegrityRules.gitignoreFileName)/inside.txt", Data("mine".utf8)) + let store = try BoardStore(rootURL: fixture.root) + + store.runScheduledHeals() + + #expect(try fixture.data("\(IntegrityRules.gitignoreFileName)/inside.txt") == Data("mine".utf8)) + #expect(store.banners.oneShots.isEmpty, "and no failure is reported for work nobody asked for") + #expect(store.banners.losses.isEmpty) + } + + /// A board whose location cannot be written to defers rather than failing — the engine's gate, + /// stated here because this heal runs at every open of every board and is the one most likely to + /// meet a read-only volume. + @Test("An unwritable board root is skipped silently") + func unwritableRootIsSkipped() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) + let store = try BoardStore(rootURL: fixture.root) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) } + + store.seedGitignore() + + #expect(!fixture.exists(IntegrityRules.gitignoreFileName)) + #expect(store.banners.oneShots.isEmpty) + #expect(store.heals.memo(for: .missingGitignore) == nil, "deferred, never remembered") + } +} + // MARK: - The housekeeping pass @MainActor diff --git a/KanbanTests/TemplateEngineTests.swift b/KanbanTests/TemplateEngineTests.swift index 34081a1..b11f477 100644 --- a/KanbanTests/TemplateEngineTests.swift +++ b/KanbanTests/TemplateEngineTests.swift @@ -174,6 +174,23 @@ struct TemplateEngineRoundTripTests { #expect(result.model.lanes.map(\.order) == [1024, 2048]) } + /// **An instantiated board is a board created today**, so it is born with the noise definition + /// every board carries (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31; the bundled + /// templates carry no `.gitignore` of their own, so this is the seeding half rather than the + /// copying one — which `TemplateEngineFixtureTests` pins from the other side, where a template + /// that *does* carry one has it copied through byte for byte and left alone). + @Test("An instantiated board carries the seeded .gitignore") + func instantiationSeedsTheIgnoreFile() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Q3 Planning.kanban") + + try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Q3 Planning") + + #expect(try Data(contentsOf: destination.appendingPathComponent(IntegrityRules.gitignoreFileName)) + == Data(BoardWriter.gitignoreSeed.utf8)) + } + @Test("The title is the document name the user chose, not the template's") func titleIsTheChosenName() throws { let fixture = try WriterFixture() diff --git a/KanbanTests/TrashStorageTests.swift b/KanbanTests/TrashStorageTests.swift index e0d188f..3c26df5 100644 --- a/KanbanTests/TrashStorageTests.swift +++ b/KanbanTests/TrashStorageTests.swift @@ -225,9 +225,13 @@ struct TrashContainerLoadTests { #expect(trashed.document.unknownFields.map(\.key) == ["project"]) } - /// Fail-fast is a property of the card parse, not of the container it ran in. - @Test("A malformed order inside .trash fails the load, naming its path") - func malformedOrderInTrashFailsFast() throws { + /// **The `order` reading is a property of the entry, not of the container it sits in** + /// (01-storage-format.md § Ordering, re-ruled 2026-07-31): an unusable rank in `.trash/` is the + /// same coercion it is on the live board — append-at-end over the container's own entries, with + /// the text as written recorded. It decides nothing here anyway, since the trash sorts by + /// `modified`; the rank is what the entry carries back out on a restore. + @Test("An unusable order inside .trash coerces, like anywhere else") + func malformedOrderInTrashCoerces() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } @@ -235,13 +239,11 @@ struct TrashContainerLoadTests { try fixture.index("", "schema: 1\n") try fixture.index(".trash/\(card)", "schema: 1\norder: soon\n") - do { - _ = try BoardLoader.load(boardRoot: fixture.root) - Issue.record("expected the load to fail") - } catch let error as BoardLoadError { - #expect(error.path == ".trash/\(card)/index.md") - #expect(error.reason == .malformedOrder(raw: "soon")) - } + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.trash.map(\.id.rawValue) == [card]) + #expect(result.model.trash[0].order == 1024) + #expect(result.coercedFrontmatter.map(\.path) == [".trash/\(card)/index.md"]) + #expect(result.coercedFrontmatter[0].fields == [CoercedField(key: "order", raw: "soon")]) } /// Symlinks are never traversed — a symlinked container would render bytes living outside the @@ -1091,11 +1093,11 @@ struct TrashKindDiscriminatorTests { #expect(result.model.trashedLanes.isEmpty) } - /// Both kinds are validated by the one rulebook: `schema` and `order` are required of a lane - /// exactly as of a card (`IntegrityRules.requiresOrder`), so a malformed entry fails fast - /// whichever kind the discriminator would have called it. - @Test("A trashed lane missing order fails the load, like any entry") - func trashedLaneFailsFastOnOrder() throws { + /// Both kinds are read by the one rulebook: a trashed lane's absent `order` reads as + /// append-at-end exactly as a trashed card's does (re-ruled 2026-07-31), and the entry loads + /// whichever kind the discriminator calls it. + @Test("A trashed lane missing order reads like any other entry") + func trashedLaneWithoutOrderReadsAsAppendAtEnd() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } let lane = uuidName() @@ -1104,9 +1106,10 @@ struct TrashKindDiscriminatorTests { try fixture.index(".trash/\(lane)", "schema: 1\nkind: lane\n") try fixture.index(".trash/\(lane)/\(uuidName())", "schema: 1\norder: 1024\n") - #expect(throws: BoardLoadError.self) { - try BoardLoader.load(boardRoot: fixture.root) - } + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.trashedLanes.map(\.id.rawValue) == [lane]) + #expect(result.model.trashedLanes[0].order == 1024) + #expect(result.model.trashedLanes[0].heldCards == 1) } /// The column is one list interleaved by **`modified` descending** (03 § Trash, re-ruled