Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

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