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
+10 -10
View File
@@ -619,14 +619,14 @@ struct AutoCommitStageAroundTests {
/// 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 {
private struct SlowComposer: ChangeNarrating {
let delay: TimeInterval
func message(for request: CommitMessageRequest) -> String {
func narrative(for request: ChangeNarrationRequest) -> 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)
return ChangeNarrator.narrative(for: request)
}
}
@@ -1336,7 +1336,7 @@ struct AutoCommitMessageTests {
/// 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)
#expect(try await flushRacingItsReload(awaitsCoverage: false) == ChangeNarrator.unnamedSubject)
}
/// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and
@@ -1345,7 +1345,7 @@ struct AutoCommitMessageTests {
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)
== ChangeNarrator.unnamedSubject)
}
}
@@ -1380,8 +1380,8 @@ struct CommitAttributionRuleTests {
try fixture.item("lane/card", stamped("Moved", by: "claude"))
let paths = [
GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true),
GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: true)
ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true),
ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: true)
]
// "**A folder move is not a deletion**: items match by id across the whole board."
#expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root).name == "claude")
@@ -1391,7 +1391,7 @@ struct CommitAttributionRuleTests {
func departuresAloneFallBack() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let paths = [GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true)]
let paths = [ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true)]
#expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root)
== CommitAttribution.externalIdentity)
}
@@ -1417,7 +1417,7 @@ struct CommitAttributionRuleTests {
let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md"))
let matching = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: false)]
let stale = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: "other")), isHeal: false)]
let changed = [GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)]
let changed = [ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)]
#expect(CommitAttribution.split(changed, under: fixture.root, receipts: matching).user == changed)
// "a foreign edit landing on an app-written path inside the same window misses the hash and
@@ -1436,7 +1436,7 @@ struct CommitAttributionRuleTests {
let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md"))
let receipts = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: true)]
let changed = [GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)]
let changed = [ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)]
let split = CommitAttribution.split(changed, under: fixture.root, receipts: receipts)
#expect(split.heal == changed)
@@ -2,11 +2,11 @@ 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.
/// **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 composer is "a pure, testable function": two board
/// **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
@@ -48,13 +48,13 @@ private func files(under root: URL) -> [String: Data] {
/// 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] {
private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] {
let old = files(under: before)
let new = files(under: after)
var paths: [GitChangedPath] = []
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(GitChangedPath(
paths.append(ChangedPath(
path: path,
isDeletion: false,
isRename: false,
@@ -62,7 +62,7 @@ private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
))
}
for path in old.keys where new[path] == nil {
paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false))
paths.append(ChangedPath(path: path, isDeletion: true, isRename: false))
}
return paths.sorted { $0.path < $1.path }
}
@@ -79,7 +79,7 @@ private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
private func compose(
board: (WriterFixture) throws -> Void = baseBoard,
change: (WriterFixture) throws -> Void,
authorship: CommitAuthorship = .user,
authorship: ChangeAuthorship = .user,
renames: Set<String> = [],
guideText: String? = nil
) throws -> String {
@@ -94,7 +94,7 @@ private func compose(
let paths = changedPaths(from: before.root, to: after.root).map { path in
renames.contains(path.path)
? GitChangedPath(
? ChangedPath(
path: path.path,
isDeletion: path.isDeletion,
isRename: true,
@@ -102,7 +102,7 @@ private func compose(
)
: path
}
return CommitMessageEngine.message(for: CommitMessageRequest(
return ChangeNarrator.narrative(for: ChangeNarrationRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
@@ -129,7 +129,7 @@ private func body(of message: String) -> [String] {
// MARK: - One event, one subject
@Suite("Commit messages ▸ a single event is the 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")
@@ -262,7 +262,7 @@ struct CommitMessageSingleEventTests {
// MARK: - The trash pair
@Suite("Commit messages ▸ the trash pair, by diff shape alone")
@Suite("Change narrator ▸ the trash pair, by diff shape alone")
struct CommitMessageTrashTests {
@Test("A move into .trash/ is Delete")
@@ -337,7 +337,7 @@ struct CommitMessageTrashTests {
// MARK: - Folding
@Suite("Commit messages ▸ folding")
@Suite("Change narrator ▸ folding")
struct CommitMessageFoldingTests {
@Test("Several of one kind fold, and a shared destination survives into the subject")
@@ -395,7 +395,7 @@ struct CommitMessageFoldingTests {
// MARK: - The external gap
@Suite("Commit messages ▸ the external gap, closed")
@Suite("Change narrator ▸ the external gap, closed")
struct CommitMessageExternalSurfaceTests {
@Test("Labels, assignees and due dates each compose a named subject")
@@ -469,13 +469,13 @@ struct CommitMessageExternalSurfaceTests {
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 {
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(GitIdentity(name: "Lanework External", email: "[email protected]")))
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)
@@ -485,7 +485,7 @@ struct CommitMessageExternalSurfaceTests {
// MARK: - Bookkeeping
@Suite("Commit messages ▸ bookkeeping composes nothing")
@Suite("Change narrator ▸ bookkeeping composes nothing")
struct CommitMessageBookkeepingTests {
@Test("A bumped modified stamp is not an event")
@@ -496,7 +496,7 @@ struct CommitMessageBookkeepingTests {
modified: "2026-07-31T12:00:00Z"
)
}
#expect(message == CommitMessageEngine.unnamedSubject)
#expect(message == ChangeNarrator.unnamedSubject)
}
@Test("A renumber's rescale preserves sequence and composes nothing")
@@ -507,7 +507,7 @@ struct CommitMessageBookkeepingTests {
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)
#expect(message == ChangeNarrator.unnamedSubject)
}
@Test("An on-touch heal's backfilled kind is not an event")
@@ -518,7 +518,7 @@ struct CommitMessageBookkeepingTests {
"---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
)
}
#expect(message == CommitMessageEngine.unnamedSubject)
#expect(message == ChangeNarrator.unnamedSubject)
}
@Test("A renumber batches with the insert that triggered it")
@@ -537,7 +537,7 @@ struct CommitMessageBookkeepingTests {
// MARK: - Titles
@Suite("Commit messages ▸ titles")
@Suite("Change narrator ▸ titles")
struct CommitMessageTitleTests {
@Test("Titles truncate in subjects only; bodies carry them whole")
@@ -568,7 +568,7 @@ struct CommitMessageTitleTests {
// MARK: - Non-snapshot paths
@Suite("Commit messages ▸ non-snapshot files")
@Suite("Change narrator ▸ non-snapshot files")
struct CommitMessagePathEventTests {
@Test("A stray composes its own path-shaped event, and several fold")
@@ -637,7 +637,7 @@ struct CommitMessagePathEventTests {
let staged = changedPaths(from: before.root, to: after.root)
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
let message = CommitMessageEngine.message(for: CommitMessageRequest(
let message = ChangeNarrator.narrative(for: ChangeNarrationRequest(
boardRoot: after.root,
changedPaths: staged,
authorship: .user,
@@ -651,7 +651,7 @@ struct CommitMessagePathEventTests {
// MARK: - The comment verb family
@Suite("Commit messages ▸ the comment verb family")
@Suite("Change narrator ▸ the comment verb family")
struct CommitMessageCommentTests {
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
@@ -798,7 +798,7 @@ struct CommitMessageCommentTests {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(message != CommitMessageEngine.unnamedSubject)
#expect(message != ChangeNarrator.unnamedSubject)
#expect(!message.contains("comments/"))
}
@@ -926,7 +926,7 @@ struct CommitMessageCommentTests {
// MARK: - Repair
@Suite("Commit messages ▸ Repair")
@Suite("Change narrator ▸ Repair")
struct CommitMessageRepairTests {
/// The remint's exact shape, assembled rather than provoked.
@@ -937,7 +937,7 @@ struct CommitMessageRepairTests {
/// 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 {
private func remintMessage(authorship: ChangeAuthorship) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
@@ -950,11 +950,11 @@ struct CommitMessageRepairTests {
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)
? ChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
: path
} + [GitChangedPath(path: departed, isDeletion: true, isRename: true)]
} + [ChangedPath(path: departed, isDeletion: true, isRename: true)]
return CommitMessageEngine.message(for: CommitMessageRequest(
return ChangeNarrator.narrative(for: ChangeNarrationRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
@@ -977,7 +977,7 @@ struct CommitMessageRepairTests {
// MARK: - The root commit
@Suite("Commit messages ▸ the root commit")
@Suite("Change narrator ▸ the root commit")
struct CommitMessageRootCommitTests {
@Test("The repository's first commit has the one fixed subject")
@@ -985,7 +985,7 @@ struct CommitMessageRootCommitTests {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try baseBoard(fixture)
let message = CommitMessageEngine.message(for: CommitMessageRequest(
let message = ChangeNarrator.narrative(for: ChangeNarrationRequest(
boardRoot: fixture.root,
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
authorship: .user,