The message engine outlives its substrate — harvested to Kanban/Changes/ as the change narrator

Step 2 of strategy/01-git-excision.md: CommitMessageEngine and the composer seam relocate to a neutral module renamed away from commit vocabulary (ChangeNarrator, ChangeNarrationRequest, ChangeNarrating, SemanticChangeNarration, ChangeAuthorship), GitChangedPath extracts from GitCommitOperation as ChangedPath, and the one git tie severs — authorship's foreign case carries a display name, not a GitIdentity. The spec tests transplant as ChangeNarratorTests, alive until the journal work begins. 3,009 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 10:38:43 -04:00
parent 6d1872ad8b
commit ae7be98eaa
8 changed files with 207 additions and 182 deletions
+998
View File
@@ -0,0 +1,998 @@
import Foundation
import Testing
@testable import Kanban
/// **Change narrator the semantic message spec** (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 narrator 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) -> [ChangedPath] {
let old = files(under: before)
let new = files(under: after)
var paths: [ChangedPath] = []
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(ChangedPath(
path: path,
isDeletion: false,
isRename: false,
isArrival: old[path] == nil
))
}
for path in old.keys where new[path] == nil {
paths.append(ChangedPath(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: ChangeAuthorship = .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)
? ChangedPath(
path: path.path,
isDeletion: path.isDeletion,
isRename: true,
isArrival: path.isArrival
)
: path
}
return ChangeNarrator.narrative(for: ChangeNarrationRequest(
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("Change narrator ▸ 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: {color: 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("Change narrator ▸ 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("Change narrator ▸ 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("Change narrator ▸ 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: ChangeAuthorship) 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("Lanework External"))
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("Change narrator ▸ 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 == ChangeNarrator.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 == ChangeNarrator.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 == ChangeNarrator.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("Change narrator ▸ 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("Change narrator ▸ 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 = ChangeNarrator.narrative(for: ChangeNarrationRequest(
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("Change narrator ▸ 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 != ChangeNarrator.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("Change narrator ▸ 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: ChangeAuthorship) 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
? ChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
: path
} + [ChangedPath(path: departed, isDeletion: true, isRename: true)]
return ChangeNarrator.narrative(for: ChangeNarrationRequest(
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("Change narrator ▸ 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 = ChangeNarrator.narrative(for: ChangeNarrationRequest(
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)
}
}