Files
lanework/KanbanTests/CommitMessageTests.swift
rzen 274ccd9ff5 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
2026-08-01 07:43:45 -04:00

999 lines
44 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import Kanban
/// **The semantic commit-message engine** (06-history-undo.md ▸ Commit messages) — the vocabulary,
/// the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the external
/// gap the section exists to close.
///
/// **No repository anywhere in this file.** The composer is "a pure, testable function": two board
/// snapshots and a changed-path list in, one message out. Both snapshots are written as bytes and
/// read back through the real `BoardLoader` (`WriterFixture.snapshot()`'s reason, restated: a
/// hand-assembled `BoardModel` would be a value the loader can never produce), and the changed-path
/// list is derived by comparing the two trees — which is what git would have reported, arrived at
/// without git. The engine-level half — that a real commit carries this message, by the real author —
/// lives in `AutoCommitTests`.
// MARK: - Fixtures
/// The board both snapshots start as: three lanes, two cards in the first.
private func baseBoard(_ fixture: WriterFixture) throws {
try fixture.board(title: "Board")
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
try fixture.lane(Ident.lane3, order: "3072", title: "Done")
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
}
/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
/// would rightly withhold.
private let spareIdentity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
/// Every file under a root, keyed by its board-root-relative path in git's spelling.
private func files(under root: URL) -> [String: Data] {
var found: [String: Data] = [:]
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: root.path) else { return found }
for case let relative as String in walker {
let url = root.appendingPathComponent(relative)
var isDirectory: ObjCBool = false
guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue
else { continue }
found[relative] = (try? Data(contentsOf: url)) ?? Data()
}
return found
}
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees — a plain
/// content comparison, since nothing here has a repository to ask.
private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
let old = files(under: before)
let new = files(under: after)
var paths: [GitChangedPath] = []
for (path, data) in new where old[path] != data {
// `isArrival` is git's own `GIT_DELTA_ADDED`, which here is simply "HEAD did not have it".
paths.append(GitChangedPath(
path: path,
isDeletion: false,
isRename: false,
isArrival: old[path] == nil
))
}
for path in old.keys where new[path] == nil {
paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false))
}
return paths.sorted { $0.path < $1.path }
}
/// Builds the same board twice, changes one copy, and composes the message for the difference.
///
/// - Parameters:
/// - board: the state both snapshots start in.
/// - change: what happened — applied to the "after" copy only.
/// - authorship: the commit's class. **Defaults to `.user`, and almost every test leaves it there**
/// because 06 rules that it must not matter: "a foreign move reads 'Move card …' exactly like an
/// app-mediated one". The one suite that varies it proves exactly that.
/// - renames: paths the survey would have paired as renames, for the shapes that turn on it.
private func compose(
board: (WriterFixture) throws -> Void = baseBoard,
change: (WriterFixture) throws -> Void,
authorship: CommitAuthorship = .user,
renames: Set<String> = [],
guideText: String? = nil
) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try board(before)
try board(after)
try change(after)
let paths = changedPaths(from: before.root, to: after.root).map { path in
renames.contains(path.path)
? GitChangedPath(
path: path.path,
isDeletion: path.isDeletion,
isRename: true,
isArrival: path.isArrival
)
: path
}
return CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot(),
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)
))
}
private func subject(of message: String) -> String {
String(message.split(separator: "\n", omittingEmptySubsequences: false).first ?? "")
}
private func body(of message: String) -> [String] {
let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
guard lines.count > 2 else { return [] }
return Array(lines.dropFirst(2))
}
// MARK: - One event, one subject
@Suite("Commit messages ▸ a single event is the subject")
struct CommitMessageSingleEventTests {
@Test("Adding a card names the card, with its destination as the detail")
func addingACard() throws {
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
}
#expect(subject(of: message) == "Add card 'Write tests'")
#expect(body(of: message) == ["to Doing"])
}
@Test("A cross-lane move reads as a move — never delete plus add")
func movingACard() throws {
// The whole-board id match: the folder left one lane and arrived in another, and the
// composer is asked to tell that from a deletion beside an arrival.
let message = try compose { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane3)/\(Ident.card1)")
}
#expect(subject(of: message) == "Move card 'Fix login' to Done")
#expect(body(of: message) == ["Todo → Done"])
}
@Test("A rename carries both names in the subject — 06's own form")
func renamingALane() throws {
let message = try compose { fixture in
try fixture.lane(Ident.lane1, order: "1024", title: "Backlog")
}
#expect(message == "Rename lane 'Todo' → 'Backlog'")
}
@Test("An edited body is an Edit, and a restyle is a Restyle")
func editingAndRestyling() throws {
let edited = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Now with detail.")
}
#expect(edited == "Edit card 'Fix login'")
let restyled = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: blue\n---\n\n"
)
}
#expect(restyled == "Restyle card 'Fix login'")
}
@Test("A widened lane is a Resize")
func resizingALane() throws {
let message = try compose { fixture in
try fixture.lane(Ident.lane1, order: "1024", title: "Todo", width: 2)
}
#expect(message == "Resize lane 'Todo' to 2×")
}
@Test("An attachment composes Attach, named by the card it landed on")
func attachingAFile() throws {
let message = try compose { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
}
#expect(message == "Attach 'spec.pdf' to card 'Fix login'")
}
@Test("A removed attachment composes Remove")
func removingAFile() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
} change: { fixture in
try FileManager.default.removeItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)/attachments").appendingPathComponent("spec.pdf")
)
}
#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
// changes. "An order change that repositions an item among its siblings composes Reorder."
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "4096", title: "Fix login")
}
#expect(message == "Reorder cards in Todo")
}
@Test("The board's own fields compose board events")
func boardLevelEvents() throws {
let renamed = try compose { fixture in
try fixture.board(title: "Q3 Plan")
}
#expect(renamed == "Rename board 'Board' → 'Q3 Plan'")
let described = try compose { fixture in
try fixture.board(title: "Board", body: "What this board is for.")
}
#expect(described == "Edit board description")
}
}
// MARK: - The trash pair
@Suite("Commit messages ▸ the trash pair, by diff shape alone")
struct CommitMessageTrashTests {
@Test("A move into .trash/ is Delete")
func intoTheTrashIsDelete() throws {
let message = try compose { fixture in
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
}
#expect(message == "Delete card 'Fix login'")
}
@Test("A move out of .trash/ is Restore")
func outOfTheTrashIsRestore() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
} change: { fixture in
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
}
#expect(message == "Restore card 'Old idea'")
}
@Test("Leaving the tree entirely is Permanently delete")
func leavingTheTreeIsPermanent() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
} change: { fixture in
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card3)"))
}
#expect(message == "Permanently delete card 'Old idea'")
}
@Test("Empty Trash folds — 'Permanently delete 3 cards', distinct from a multi-select delete")
func emptyTrashFolds() throws {
let purge = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "One")
try fixture.trashCard(Ident.card4, order: "2048", title: "Two")
try fixture.trashCard(spareIdentity, order: "3072", title: "Three")
} change: { fixture in
try FileManager.default.removeItem(at: fixture.url(".trash"))
}
#expect(subject(of: purge) == "Permanently delete 3 cards")
let delete = try compose { fixture in
try baseBoard(fixture)
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
} change: { fixture in
for card in [Ident.card1, Ident.card2, Ident.card3] {
try fixture.move("\(Ident.lane1)/\(card)", toTrash: card)
}
}
#expect(subject(of: delete) == "Delete 3 cards")
}
@Test("A trashed lane takes its cards with it — 'Delete lane', cards as bullets")
func aTrashedLaneKeepsTheSubject() throws {
// "Implied events don't steal the subject: deleting a lane with five cards reads 'Delete lane
// 'X'' with the card deletions as body bullets — not 'Update board'." And the cards are
// *deleted*, not permanently deleted: they went into the trash inside their lane's folder.
let message = try compose { fixture in
try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)")
}
#expect(subject(of: message) == "Delete lane 'Todo'")
#expect(body(of: message) == [
"- Delete lane 'Todo'",
"- Delete card 'Fix login'",
"- Delete card 'Ship it'",
])
}
}
// MARK: - Folding
@Suite("Commit messages ▸ folding")
struct CommitMessageFoldingTests {
@Test("Several of one kind fold, and a shared destination survives into the subject")
func sharedDestinationsFold() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
} change: { fixture in
for card in [Ident.card1, Ident.card2, Ident.card3] {
try fixture.moveFolder("\(Ident.lane1)/\(card)", to: "\(Ident.lane3)/\(card)")
}
}
#expect(subject(of: message) == "Move 3 cards to Done")
#expect(body(of: message).count == 3)
#expect(body(of: message).first == "- Move card 'Fix login' from Todo to Done")
}
@Test("Destinations that disagree drop out of the subject rather than lying")
func disagreeingDestinationsDropOut() throws {
let message = try compose(change: { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
})
#expect(subject(of: message) == "Move 2 cards")
}
@Test("A genuinely mixed window says so — with every event in the body")
func mixedWindowsFallBack() throws {
// Re-ruled 2026-07-31: the bare "Update board" is retired. Two cards, so no shared item and
// no name to keep — "Mixed update — N changes", never a shrug dressed as one thing.
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout")
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
}
#expect(subject(of: message) == "Mixed update — 2 changes")
#expect(body(of: message) == [
"- Add card 'Write tests' to Doing",
"- Rename card 'Fix login' → 'Fix logout'",
])
}
@Test("A deleted lane's cards are body bullets, never the subject")
func impliedEventsDoNotStealTheSubject() throws {
let message = try compose { fixture in
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
}
#expect(subject(of: message) == "Permanently delete lane 'Todo'")
#expect(body(of: message) == [
"- Permanently delete lane 'Todo'",
"- Permanently delete card 'Fix login'",
"- Permanently delete card 'Ship it'",
])
}
}
// MARK: - The external gap
@Suite("Commit messages ▸ the external gap, closed")
struct CommitMessageExternalSurfaceTests {
@Test("Labels, assignees and due dates each compose a named subject")
func theReservedTrioComposes() throws {
func withKey(_ line: String) throws -> String {
try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\n\(line)\n---\n\n"
)
}
}
#expect(try withKey("labels: [bug, urgent]") == "Relabel card 'Fix login'")
#expect(try withKey("assignees: [ada]") == "Assign card 'Fix login'")
#expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'")
}
@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\n---\n\n"
)
}
#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")
func originIsNotInTheProse() throws {
// "Origin lives in the author field (structural attribution), not in message prose — a foreign
// move reads 'Move card …' exactly like an app-mediated one." Same change, all three classes.
func move(_ authorship: CommitAuthorship) throws -> String {
try compose(change: { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
}, authorship: authorship)
}
let app = try move(.user)
let foreign = try move(.foreign(GitIdentity(name: "Lanework External", email: "[email protected]")))
let heal = try move(.heal)
#expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
#expect(foreign == app)
#expect(heal == app)
}
}
// MARK: - Bookkeeping
@Suite("Commit messages ▸ bookkeeping composes nothing")
struct CommitMessageBookkeepingTests {
@Test("A bumped modified stamp is not an event")
func stampsAreSilent() throws {
let message = try compose { fixture in
try fixture.card(
Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login",
modified: "2026-07-31T12:00:00Z"
)
}
#expect(message == CommitMessageEngine.unnamedSubject)
}
@Test("A renumber's rescale preserves sequence and composes nothing")
func rescalesAreSilent() throws {
// Every rank multiplied, nobody repositioned — 01-storage-format.md's renumber. "Sequence is
// what the diff compares, not raw `order` values."
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login")
try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it")
}
#expect(message == CommitMessageEngine.unnamedSubject)
}
@Test("An on-touch heal's backfilled kind is not an event")
func theBackfilledKindIsSilent() throws {
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
)
}
#expect(message == CommitMessageEngine.unnamedSubject)
}
@Test("A renumber batches with the insert that triggered it")
func aRenumberRidesWithItsInsert() throws {
// Midpoint exhaustion: the insert forces every sibling's rank to be rewritten. The commit
// reads as the insert, because the *sequence* of the cards that were already there is
// unchanged.
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
try fixture.card(Ident.card3, in: Ident.lane1, order: "2048", title: "Wedged in")
try fixture.card(Ident.card2, in: Ident.lane1, order: "3072", title: "Ship it")
}
#expect(subject(of: message) == "Add card 'Wedged in'")
}
}
// MARK: - Titles
@Suite("Commit messages ▸ titles")
struct CommitMessageTitleTests {
@Test("Titles truncate in subjects only; bodies carry them whole")
func truncationIsSubjectOnly() throws {
let long = String(repeating: "A", count: 60)
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
try fixture.card(Ident.card4, in: Ident.lane2, order: "2048", title: long)
}
#expect(subject(of: message) == "Add 2 cards to Doing")
#expect(body(of: message).allSatisfy { $0.contains(long) })
let sole = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
}
#expect(subject(of: sole) == "Add card '\(String(repeating: "A", count: 40))…'")
}
@Test("An untitled item reads (untitled), never a bare pair of quotes")
func untitledIsRendered() throws {
let message = try compose { fixture in
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
}
#expect(message == "Add card '(untitled)'\n\nto Doing")
#expect(!message.contains("''"))
}
}
// MARK: - Non-snapshot paths
@Suite("Commit messages ▸ non-snapshot files")
struct CommitMessagePathEventTests {
@Test("A stray composes its own path-shaped event, and several fold")
func straysComposePathEvents() throws {
let one = try compose { fixture in
try fixture.file("notes.txt", Data("hello".utf8))
}
#expect(one == "Update 'notes.txt'")
let several = try compose { fixture in
try fixture.file("notes.txt", Data("hello".utf8))
try fixture.file(".gitignore", Data("*.tmp\n".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.md", Data("scratch".utf8))
}
#expect(subject(of: several) == "Update 3 files")
#expect(body(of: several) == [
"- Update '.gitignore'",
"- Update '\(Ident.lane1)/\(Ident.card1)/scratch.md'",
"- Update 'notes.txt'",
])
}
@Test("The agent guide composes its version, read from the marker line")
func theAgentGuideComposesItsVersion() throws {
let guide = "<!-- lanework-agent-guide v7 -->\nHow to work in this board.\n"
let message = try compose(change: { fixture in
try fixture.file(AgentGuide.filename, Data(guide.utf8))
}, guideText: guide)
#expect(message == "Update agent guide (v7)")
}
@Test("A markerless CLAUDE.md is somebody else's file, and composes as the path it is")
func amarkerlessGuideIsJustAFile() throws {
let text = "My own notes for agents.\n"
let message = try compose(change: { fixture in
try fixture.file(AgentGuide.filename, Data(text.utf8))
}, guideText: text)
#expect(message == "Update 'CLAUDE.md'")
}
@Test("Model events keep the subject; a stray rides along as a bullet")
func modelEventsKeepTheSubject() throws {
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
try fixture.file("notes.txt", Data("hello".utf8))
}
#expect(subject(of: message) == "Add card 'Write tests'")
#expect(body(of: message) == [
"- Add card 'Write tests' to Doing",
"- Update 'notes.txt'",
])
}
@Test("A path this commit does not stage composes nothing — the stage-around, in the message")
func pathsOutsideTheCommitAreNotDescribed() throws {
// The split window's rule, and the open-Edit-session exclusion's: the composer describes the
// commit it is composing for, not everything that differs from HEAD.
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try baseBoard(before)
try baseBoard(after)
try after.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Half-typed.")
try after.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
let staged = changedPaths(from: before.root, to: after.root)
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
let message = CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: staged,
authorship: .user,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot()
))
#expect(message == "Add card 'Write tests'\n\nto Doing")
}
}
// MARK: - The comment verb family
@Suite("Commit messages ▸ the comment verb family")
struct CommitMessageCommentTests {
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
private static let commentB = "cccccccc-0000-4000-8000-000000000002"
private static let commentC = "cccccccc-0000-4000-8000-000000000003"
/// A comment's `index.md` — never parsed by the composer, which reads the family off path shape
/// alone ("comments are window-scoped, outside the board snapshot" — 01-storage-format.md).
private static func commentText(_ body: String) -> Data {
Data("---\nschema: 1\nkind: comment\nauthor: Ada\n---\n\(body)\n".utf8)
}
private static func thread(_ card: String, _ entry: String) -> String {
"\(Ident.lane1)/\(card)/comments/\(entry)/index.md"
}
@Test("Posting — the .draft rename — composes 'Comment on ⟨card⟩'")
func postingComposesComment() throws {
// The app's post is one rename: `comments/.draft/` → `comments/<uuid>/`, restamped in the
// same bracket. The departing `.draft` end is silent under the rename rule; the arrival is
// the event.
let draft = Self.thread(Ident.card1, ".draft")
let posted = Self.thread(Ident.card1, Self.commentA)
let message = try compose(board: { fixture in
try baseBoard(fixture)
try fixture.file(draft, Self.commentText("Half a thought."))
}, change: { fixture in
try fixture.file(posted, Self.commentText("Half a thought."))
try FileManager.default.removeItem(
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.draft")
)
}, renames: [draft, posted])
#expect(message == "Comment on 'Fix login'")
}
@Test("A foreign writer's new comment folder composes the same words")
func foreignArrivalsComposeTheSame() throws {
// No rename to read: an agent simply wrote a folder. Origin-agnostic one level down.
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("From an agent."))
}
#expect(message == "Comment on 'Fix login'")
}
@Test("Rewriting an existing comment composes 'Edit comment on ⟨card⟩'")
func editsComposeEditComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("First draft."))
} change: { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Second thoughts."))
}
#expect(message == "Edit comment on 'Fix login'")
}
@Test("A move into comments/.trash/ composes one Delete, not a departure plus an arrival")
func deletionComposesDeleteComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Regretted."))
} change: { fixture in
try fixture.moveFolder(
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)",
to: "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)"
)
}
// Both halves are in the window and git paired neither of them; the arriving end speaks.
#expect(message == "Delete comment on 'Fix login'")
}
@Test("The close purge composes 'Permanently delete comment on ⟨card⟩'")
func purgeComposesPermanentDelete() throws {
// `comments/.trash/` is undo's backing store, purged when the card window closes. Unruled in
// DESIGN; composed by the trash pair's own symmetry rather than left as a silent shrug.
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(
"\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)/index.md",
Self.commentText("Deleted a while ago.")
)
} change: { fixture in
try FileManager.default.removeItem(
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.trash")
)
}
#expect(message == "Permanently delete comment on 'Fix login'")
}
@Test("A draft save composes the quiet 'Draft comment on ⟨card⟩'")
func draftSavesComposeDraftComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing"))
} change: { fixture in
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing, more"))
}
#expect(message == "Draft comment on 'Fix login'")
}
@Test("Every file in one comment folder is one event")
func oneCommentIsOneEvent() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("With a file."))
try fixture.file(
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)/attachments/shot.png",
Data("png".utf8)
)
}
#expect(message == "Comment on 'Fix login'")
}
@Test("Several comments fold on the card they landed on")
func commentsFoldOnTheirCard() throws {
let sameCard = try compose { fixture in
for comment in [Self.commentA, Self.commentB, Self.commentC] {
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
}
}
#expect(subject(of: sameCard) == "3 comments on 'Fix login'")
let twoCards = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
try fixture.file(Self.thread(Ident.card2, Self.commentB), Self.commentText("Note."))
}
#expect(subject(of: twoCards) == "Comment on 2 cards")
}
@Test("The card's title is resolved from the snapshot, untitled included")
func titlesResolveFromTheSnapshot() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
} change: { fixture in
try fixture.file(
"\(Ident.lane2)/\(Ident.card3)/comments/\(Self.commentA)/index.md",
Self.commentText("On a nameless card.")
)
}
#expect(message == "Comment on '(untitled)'")
}
@Test("A comment-only window never shrugs and never shows a raw path")
func aCommentOnlyWindowGetsItsOwnSubject() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(message != CommitMessageEngine.unnamedSubject)
#expect(!message.contains("comments/"))
}
@Test("Model events keep the subject; a comment rides along as a bullet")
func modelEventsOutrankComments() throws {
let message = try compose { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(subject(of: message) == "Move card 'Ship it' to Done")
#expect(body(of: message).contains("- Comment on 'Fix login'"))
}
@Test("A card carrying its thread into the trash is one event, not one per comment")
func aMovedCardSwallowsItsThread() throws {
// The implied-events rule, one level down: the comment files travelled because the card did.
let message = try compose { fixture in
try baseBoard(fixture)
for comment in [Self.commentA, Self.commentB] {
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
}
} change: { fixture in
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
}
#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
@Suite("Commit messages ▸ Repair")
struct CommitMessageRepairTests {
/// The remint's exact shape, assembled rather than provoked.
///
/// What `BoardWriter.remintDuplicateIdentity` does on disk is rename the losing folder in place to
/// a fresh identity, so the survey reports a rename pair. What the *snapshots* show is only the
/// arrival — because the previous load withheld the duplicate (`BoardLoader.dedupeIdentities`),
/// its id was never in a board model at all. Provoking that by writing two folders with one id
/// would make the test depend on which twin the dedupe happened to keep; the shape the composer
/// is asked about is this, and it is stated directly.
private func remintMessage(authorship: CommitAuthorship) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try baseBoard(before)
try baseBoard(after)
try after.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Fix login")
let departed = "\(Ident.lane1)/\(Ident.card3)/index.md"
let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
let paths = changedPaths(from: before.root, to: after.root).map { path in
path.path == arrived
? GitChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
: path
} + [GitChangedPath(path: departed, isDeletion: true, isRename: true)]
return CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot()
))
}
@Test("A heal window's folder remint reads as Repair, not as an arrival")
func theRemintComposesRepair() throws {
#expect(try remintMessage(authorship: .heal) == "Repair duplicate of 'Fix login'")
}
@Test("The same shape outside a heal window is an ordinary arrival")
func repairIsHealOnly() throws {
#expect(subject(of: try remintMessage(authorship: .user)) == "Add card 'Fix login'")
}
}
// MARK: - The root commit
@Suite("Commit messages ▸ the root commit")
struct CommitMessageRootCommitTests {
@Test("The repository's first commit has the one fixed subject")
func theRootCommitIsFixed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try baseBoard(fixture)
let message = CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: fixture.root,
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
authorship: .user,
isRootCommit: true,
snapshot: try fixture.snapshot(),
previousSnapshot: nil
))
#expect(message == GitRepository.initialCommitSubject)
}
}