Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
-7
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Board Is Fine
|
||||
---
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Card Missing Order
|
||||
---
|
||||
No 'order' key — required on every card.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
order: 1024
|
||||
title: Lane Is Fine
|
||||
---
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Board Is Fine
|
||||
---
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Lane Missing Order
|
||||
---
|
||||
No 'order' key — required on every lane.
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Board Is Fine
|
||||
---
|
||||
-7
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
schema: 1
|
||||
title: Board Is Fine
|
||||
---
|
||||
+4
-5
@@ -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.
|
||||
|
||||
+8
@@ -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.
|
||||
+6
@@ -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.
|
||||
+8
@@ -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.
|
||||
+8
@@ -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.
|
||||
+9
@@ -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.
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
order: 1024
|
||||
title: Lane Is Fine
|
||||
title: Ranked Lane
|
||||
---
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: card
|
||||
order: 1024
|
||||
title: Card In The Orderless Lane
|
||||
---
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
schema: 1
|
||||
kind: lane
|
||||
title: Orderless Lane
|
||||
---
|
||||
No rank at all: sorts right of every ranked lane in the strip.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
+102
-15
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "[email protected]"
|
||||
|
||||
/// 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 `<slug>@agents.lanework.invalid` (display name verbatim, email local part
|
||||
/// slugified)".
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = [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.
|
||||
///
|
||||
/// `<lane>/<card>/attachments/<file>`, 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 — `<card>/comments/<uuid>`,
|
||||
/// `<card>/comments/.trash/<uuid>` or `<card>/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 — `<lane>/<card>/comments/<entry>` for a
|
||||
// comment or the draft, one deeper for `comments/.trash/<uuid>`.
|
||||
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/<uuid>/` 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<String> {
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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<Void, Never>] = []
|
||||
|
||||
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<String> = []
|
||||
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 <integrity@lanework.invalid>`** (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
|
||||
|
||||
@@ -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)?
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..<separator].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
|
||||
switch key {
|
||||
@@ -177,10 +189,10 @@ enum GitConfigFile {
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
/// **The popover's identity fields, landing in the file** (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").
|
||||
/// **The identity fields, landing in the file** (06-history-undo.md ▸ Interaction 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"; the fields are popover-hosted until that sheet is built).
|
||||
///
|
||||
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
|
||||
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
|
||||
@@ -225,10 +237,9 @@ enum GitConfigFile {
|
||||
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
|
||||
/// aimed somewhere else — much the worse error, whatever the read side does with it.
|
||||
///
|
||||
/// (The read side, `identity(inConfigText:)`, deliberately takes the last matching value it
|
||||
/// meets whichever section it is in — its own recorded call. The two agree in practice for
|
||||
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
|
||||
/// its keys are the last ones the reader meets.)
|
||||
/// (The read side, `identity(inConfigText:)`, scopes itself to plain sections for the same
|
||||
/// reason and takes the last one's value, so the two halves agree by construction rather than
|
||||
/// by coincidence.)
|
||||
var isPlainUserSection = false
|
||||
/// Where a key the file does not yet have would be inserted: just after the last line of the
|
||||
/// plain `[user]` section, or `nil` while there is no such section.
|
||||
|
||||
@@ -50,9 +50,10 @@ public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConve
|
||||
///
|
||||
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
|
||||
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
|
||||
/// its own schedule. The one seed it does write — a `.gitignore`, at init and never again
|
||||
/// (06 ▸ Repository hygiene) — is the app's last word on that file rather than the start of a
|
||||
/// relationship with it.
|
||||
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
|
||||
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
|
||||
/// healed in at open), so all that survives here is a last-chance check that the file exists before
|
||||
/// the initial commit freezes the tree — see `seedGitignoreIfAbsent(at:)`.
|
||||
enum GitRepository {
|
||||
|
||||
/// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states,
|
||||
@@ -72,20 +73,11 @@ enum GitRepository {
|
||||
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
|
||||
/// the repo.
|
||||
///
|
||||
/// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice.
|
||||
/// **The initial branch is `main`** (06 ▸ Rules ▸ Opt-in init, blessed 2026-07-31): "the host's
|
||||
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
|
||||
/// deterministically — git's modern default, the pathfinder's choice."
|
||||
static let initialBranchName = "main"
|
||||
|
||||
/// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene: "Adding git
|
||||
/// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists").
|
||||
///
|
||||
/// One line, because one line is what the rule says and because every additional entry would be
|
||||
/// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is
|
||||
/// the entry that earns its place: 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 — noise that would otherwise be committed by the whole-tree stage, forever,
|
||||
/// under the user's own name.
|
||||
static let seededGitignore = ".DS_Store\n"
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
// MARK: Opt-in init
|
||||
@@ -97,24 +89,39 @@ enum GitRepository {
|
||||
/// is protected from the moment git exists" — so the two halves are one operation and a failure
|
||||
/// in either is one failure.
|
||||
///
|
||||
/// Between them sits the one seed the app ever writes: a minimal `.gitignore`, if the board has
|
||||
/// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`).
|
||||
/// Between them sits a last-chance `.gitignore` check — the file is the board's rather than
|
||||
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
|
||||
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
|
||||
///
|
||||
/// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't
|
||||
/// create" (06), and `git_repository_init` over an existing repository is a re-initialization —
|
||||
/// harmless in the common case and precisely the kind of thing that rule exists to forbid. The
|
||||
/// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that
|
||||
/// makes it impossible rather than merely unlikely.
|
||||
/// **Create re-runs full detection and refuses anything but mode none** (06 ▸ Rules ▸ Detection,
|
||||
/// ruled 2026-07-31): "as hardening, 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."
|
||||
///
|
||||
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
|
||||
/// detected at board open, which can be minutes old — a `git init` in a terminal at the board root
|
||||
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
|
||||
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
|
||||
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
|
||||
/// probable. (Detection has no *unverifiable* answer yet — 06's denial-is-not-absence distinction
|
||||
/// is not built — so "clean none" is spelled `.none` for now.)
|
||||
///
|
||||
/// Returns the branch the root commit landed on, which is the popover's display line.
|
||||
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
|
||||
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)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<HistoryAnchor>
|
||||
|
||||
/// 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<HistoryAnchor> = [],
|
||||
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<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
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<HistoryAnchor> { 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<HistoryAnchor> { [] }
|
||||
}
|
||||
|
||||
@@ -39,8 +39,16 @@ import Foundation
|
||||
/// A step that applies is pushed onto the opposite stack **reversed** — its two halves swapped
|
||||
/// (`HistoryStep.reversed`) — which gives the whole classic dance (undo → redo → undo …) with one
|
||||
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
|
||||
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
|
||||
/// a skipped step leaves nothing crossable behind: it is popped and never re-pushed, which is 13's
|
||||
/// "popped from the stack ... and ⌘Z falls through to the next step".
|
||||
///
|
||||
/// ### A third collection, which is not a stack
|
||||
///
|
||||
/// What a stale skip leaves behind is not *nothing*, though, and that is the one place this type
|
||||
/// keeps state a stack cannot express: a skipped step that was holding undo backing goes on holding
|
||||
/// it until the session ends (`strandedSteps`; 13 ▸ Interaction with the trash, ruled 2026-07-31 —
|
||||
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
|
||||
/// menu; it is a hold outliving the history it belonged to.
|
||||
@MainActor
|
||||
public final class NativeHistoryProvider: HistoryProviding {
|
||||
|
||||
@@ -48,6 +56,26 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
private var undoSteps: [HistoryStep] = []
|
||||
private var redoSteps: [HistoryStep] = []
|
||||
|
||||
/// **Steps a stale skip popped that were still holding something** — off both stacks, uncrossable
|
||||
/// forever, and *not yet retired* (13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31):
|
||||
///
|
||||
/// > "the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded,
|
||||
/// > or dropped off the end — or when the board session ends; **a stale-skipped step's backing
|
||||
/// > instead survives to board-session end** ... the skip banner says nothing was applied, and an
|
||||
/// > irreversible purge riding that gesture would be surprise loss — the skip is exactly when the
|
||||
/// > user may want to inspect what the collision left."
|
||||
///
|
||||
/// **The whole step is kept, not a copy of what it was holding**, and that is the ruling's "one
|
||||
/// condition, two consumers" taken literally: the sweep's ownership question (`backedContent`) and
|
||||
/// the deferred purge's moment (`HistoryStep.Retirement`) are two readings of one object's hold,
|
||||
/// so they must not become two facts able to disagree. A skip that ran the retirement and re-armed
|
||||
/// the sweep from a side-table would be saying *released* to one consumer and *still held* to the
|
||||
/// other, for the same instant. Deferring the latch itself says it once.
|
||||
///
|
||||
/// Only steps that owe something are stranded — see `strand(_:)`.
|
||||
private var strandedSteps: [HistoryStep] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
@@ -73,16 +101,44 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// membership question, and the two arrays already answer it.
|
||||
public var pendingSteps: [HistoryStep] { undoSteps }
|
||||
|
||||
/// **What every step this substrate still holds is holding** — the sweep's gate
|
||||
/// (`HistoryProviding.backedContent`; 13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31).
|
||||
///
|
||||
/// All *three* collections, unlike `pendingSteps` above, and the difference is the question: that
|
||||
/// one asks what ⌘Z would cross *now*, this one asks what has not been released. A step on the
|
||||
/// redo stack has not retired — "undone-and-superseded" is a release condition precisely because
|
||||
/// being undone is not one — so its backing is still owed; and a stranded step is the case where
|
||||
/// the two questions come furthest apart, holding backing it will never again cross for
|
||||
/// (`strandedSteps`, the skip-purge decoupling ruled 2026-07-31). The readings differing is what
|
||||
/// makes them two members.
|
||||
///
|
||||
/// Leaving the stranded out would defeat the ruling by the back door: the step is off the stack,
|
||||
/// so the next window open's sweep would call its backing residue and purge exactly the content
|
||||
/// the skip was meant to leave standing.
|
||||
public var backedContent: Set<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
for step in undoSteps { backing.formUnion(step.backing) }
|
||||
for step in redoSteps { backing.formUnion(step.backing) }
|
||||
for step in strandedSteps { backing.formUnion(step.backing) }
|
||||
return backing
|
||||
}
|
||||
|
||||
/// Records one undoable step and clears the redo stack — the classic rule, and the one every
|
||||
/// substrate shares.
|
||||
///
|
||||
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
|
||||
/// moment the app can see it.
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition — a *clean* exit in the
|
||||
/// ruling's own word — and it is the only moment the app can see it.
|
||||
///
|
||||
/// `strandedSteps` is deliberately untouched: superseding is something that happens to a step on
|
||||
/// the redo stack, and a stranded step is on no stack to be superseded from. Its one exit is the
|
||||
/// session's end.
|
||||
public func register(_ step: HistoryStep) {
|
||||
undoSteps.append(step)
|
||||
retire(redoSteps)
|
||||
let dropped = redoSteps
|
||||
redoSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
public func undo() { cross(.undo) }
|
||||
@@ -92,17 +148,38 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// Session teardown, the add-git substrate swap, a branch reseed — every step goes, so every step
|
||||
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
|
||||
/// ends" (13 ▸ Interaction with the trash).
|
||||
///
|
||||
/// **This is where the stranded finally pay**, and the only place they can: "a stale-skipped
|
||||
/// step's backing instead survives to board-session end" (ruled 2026-07-31) names exactly this
|
||||
/// moment, and `AppModel`'s teardown reaches it with the store still alive — the `clear()` is a
|
||||
/// line above the store's release, for the reason the deferred purge needs it to be.
|
||||
public func clear() {
|
||||
retire(undoSteps)
|
||||
retire(redoSteps)
|
||||
let dropped = undoSteps + redoSteps + strandedSteps
|
||||
undoSteps.removeAll()
|
||||
redoSteps.removeAll()
|
||||
strandedSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
/// **Off the stack first, then retired** — the order every caller above keeps, and the one
|
||||
/// `backedContent` depends on: a retirement is where the deferred purge runs, and a step that
|
||||
/// retired while still listed would be answering that it still holds the very content it is
|
||||
/// releasing.
|
||||
private func retire(_ steps: [HistoryStep]) {
|
||||
for step in steps { step.retirement?.run() }
|
||||
}
|
||||
|
||||
/// Keeps a skipped step's *hold* alive without keeping the step crossable — see `strandedSteps`.
|
||||
///
|
||||
/// **A step holding nothing is dropped outright**, which is every board gesture: with no backing
|
||||
/// to spare and no retirement to defer there is no consumer for the entry, and stranding it would
|
||||
/// be bookkeeping that only grows. The predicate is the pair the ruling's two consumers read, so
|
||||
/// it can never spare a step neither of them would ask about.
|
||||
private func strand(_ step: HistoryStep) {
|
||||
guard step.retirement != nil || !step.backing.isEmpty else { return }
|
||||
strandedSteps.append(step)
|
||||
}
|
||||
|
||||
// MARK: - The crossing
|
||||
|
||||
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 13's
|
||||
@@ -119,9 +196,13 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
push(step.reversed, onto: direction.opposite)
|
||||
return
|
||||
case .skipped:
|
||||
// Dropped for good — the third of the deferred purge's release conditions ("gone
|
||||
// stale"), and the reason a skip is reported here rather than merely counted.
|
||||
step.retirement?.run()
|
||||
// **Off the stack, but not released** — the skip-purge decoupling (13 ▸ Interaction
|
||||
// with the trash, ruled 2026-07-31). A stale skip is the one exit that is not clean:
|
||||
// "the skip banner says nothing was applied, and an irreversible purge riding that
|
||||
// gesture would be surprise loss — the skip is exactly when the user may want to
|
||||
// inspect what the collision left". So the step stops being crossable here and its
|
||||
// hold outlives it, to the session's end (`strandedSteps`).
|
||||
strand(step)
|
||||
continue
|
||||
case .failed:
|
||||
push(step, onto: direction)
|
||||
|
||||
@@ -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<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [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
|
||||
|
||||
@@ -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<String> = 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 `<root>/.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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<lane>/$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.
|
||||
|
||||
@@ -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
|
||||
/// `<root>/.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<String> = 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<String> { 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 (`<lane>/<card>/<name>`), 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 — `<lane>/<card>` — 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 `<root>/<lane>/<card>` 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.
|
||||
|
||||
@@ -169,11 +169,19 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
/// 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<Double>`.
|
||||
/// 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<Double>`: 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<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
/// 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").
|
||||
|
||||
@@ -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 `.<name>.lanework-<uuid>` 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
|
||||
/// `<root>/<lane>/<card>` 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 — `<root>/<lane>/<card>` 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)
|
||||
|
||||
@@ -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<Value: Sendable & Equatable>: 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<Int> {
|
||||
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<Double> {
|
||||
read(FrontmatterKeys.order) { value, _ in
|
||||
switch value {
|
||||
|
||||
@@ -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 (`<lane>/<card>/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<Segment>, _ path: ArraySlice<String>) -> 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<Token>, _ name: ArraySlice<Character>) -> 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<Character>)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
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<T>(
|
||||
_ lhs: T, before rhs: T,
|
||||
order: (T) -> Double, name: (T) -> String
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Double> {
|
||||
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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 == "[email protected]", "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 == "[email protected]")
|
||||
|
||||
// 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 == "[email protected]")
|
||||
|
||||
// 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 = [email protected]\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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<root>/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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user