Convert the Writer's operation vocabulary to a closed enum
WriteOperation replaces free-form operation strings throughout BoardWriter, per the settled rule in 02 § Write-failure surfacing: the banner layer will switch exhaustively over it, so a new operation without a banner rendering is a compile-time hole. Titles enrich at the two points a document read makes them known (updateIndex, and the move/copy pre-flight), so failures after the read name the item; purge never reads and stays title-less. Free-form English survives only in the diagnostic reason. Full suite 300 tests in 56 suites green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -471,7 +471,7 @@ struct BoardStoreTests {
|
||||
|
||||
// A Writer operation that fails partway has still touched disk, so the bracket has to close
|
||||
// on the throwing path too — an unbalanced one would suspend the watcher for the session.
|
||||
let boom = BoardWriteError(operation: "probe", path: "/nowhere", reason: .io(message: "disk full"))
|
||||
let boom = BoardWriteError(operation: .style(title: nil), path: "/nowhere", reason: .io(message: "disk full"))
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in throw boom }
|
||||
Issue.record("expected the operation's own error to propagate")
|
||||
|
||||
@@ -94,7 +94,7 @@ struct BoardWriterPreservationTests {
|
||||
defer { fixture.tearDown() }
|
||||
let folder = try fixture.item("card", Fixture.rich)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ struct BoardWriterPreservationTests {
|
||||
let folder = try fixture.item("card", Fixture.rich)
|
||||
let body = Data("Body text.\n\nMore body — with *markdown*.\n".utf8)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "reorder card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(2048))
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ struct BoardWriterPreservationTests {
|
||||
defer { fixture.tearDown() }
|
||||
let folder = try fixture.item("card", Fixture.minimal)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ struct BoardWriterPreservationTests {
|
||||
+ "modified: 2026-01-01T00:00:00Z\r\nmodified-by: claude\r\n---\r\nbody\r\n"
|
||||
let folder = try fixture.item("card", text)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ struct BoardWriterStampTests {
|
||||
let folder = try fixture.item("card", Fixture.rich)
|
||||
#expect(try FrontmatterDocument.parse(fixture.indexText("card")).modifiedBy == .valid("claude"))
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ struct BoardWriterStampTests {
|
||||
defer { fixture.tearDown() }
|
||||
let folder = try fixture.item("card", Fixture.minimal)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date(timeIntervalSince1970: 0)))
|
||||
document.set(FrontmatterKeys.modifiedBy, to: .string("claude"))
|
||||
}
|
||||
@@ -210,7 +210,7 @@ struct BoardWriterStampTests {
|
||||
defer { fixture.tearDown() }
|
||||
let folder = try fixture.item("card", Fixture.minimal)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
|
||||
@@ -244,14 +244,15 @@ struct BoardWriterUneditableTests {
|
||||
#expect(document.order == .valid(1024))
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
}
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(error?.operation == "rename card")
|
||||
// No title in `Fixture.flowMapping`, so the pre-flight read leaves it nil.
|
||||
#expect(error?.operation == .style(title: nil))
|
||||
#expect(error?.path.hasSuffix("card/index.md") == true)
|
||||
#expect(error?.description.contains("rename card") == true)
|
||||
#expect(error?.description.contains("style") == true)
|
||||
#expect(try fixture.indexText("card") == Fixture.flowMapping)
|
||||
#expect(try fixture.entryNames("card") == ["index.md"])
|
||||
}
|
||||
@@ -265,7 +266,7 @@ struct BoardWriterUneditableTests {
|
||||
#expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey)
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
}
|
||||
@@ -290,7 +291,7 @@ struct BoardWriterFailureTests {
|
||||
let folder = try fixture.item("card", bytes: latin1)
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
}
|
||||
@@ -310,7 +311,7 @@ struct BoardWriterFailureTests {
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { _ in }
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
guard case .unreadable = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
@@ -326,7 +327,7 @@ struct BoardWriterFailureTests {
|
||||
let folder = try fixture.item("card", text)
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { _ in }
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
guard case .unreadable = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
@@ -347,7 +348,7 @@ struct BoardWriterFailureTests {
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) }
|
||||
|
||||
let error = writeFailure {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||
}
|
||||
}
|
||||
@@ -355,7 +356,10 @@ struct BoardWriterFailureTests {
|
||||
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(error?.operation == "rename card")
|
||||
// `updateIndex` read `Fixture.rich` (title: Original) successfully before the write
|
||||
// itself failed — title enrichment engages even though the failure is `.io`, not a
|
||||
// read/uneditable refusal.
|
||||
#expect(error?.operation == .style(title: "Original"))
|
||||
#expect(error?.path.hasSuffix("card/index.md") == true)
|
||||
#expect(try fixture.indexData("card") == before)
|
||||
#expect(try fixture.entryNames("card") == ["index.md"])
|
||||
@@ -458,7 +462,7 @@ struct BoardWriterRenumberTests {
|
||||
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
|
||||
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
||||
#expect(error?.path.contains(Child.b) == true)
|
||||
#expect(error?.operation == "renumber children")
|
||||
#expect(error?.operation == .renumberChildren)
|
||||
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
|
||||
}
|
||||
|
||||
@@ -525,7 +529,7 @@ struct BoardWriterLoaderIntegrationTests {
|
||||
let lane = try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n")
|
||||
try fixture.item("\(Child.a)/\(Child.b)", "---\nschema: 1\norder: 1024\ntitle: Card\n---\nbody\n")
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: lane, operation: "rename lane") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: lane, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("Doing"))
|
||||
}
|
||||
|
||||
@@ -734,7 +738,7 @@ struct BoardWriterCreateChildTests {
|
||||
}
|
||||
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
||||
#expect(error?.path.contains(Child.a) == true)
|
||||
#expect(error?.operation == "create lane")
|
||||
#expect(error?.operation == .createLane)
|
||||
// Nothing was minted: the scan fails before the new folder is ever created.
|
||||
#expect(try fixture.entryNames("") == before)
|
||||
}
|
||||
@@ -1122,7 +1126,9 @@ struct BoardWriterMoveTests {
|
||||
try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card3)", to: "A.kanban/\(Ident.lane2)")
|
||||
}
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(error?.operation == "move item")
|
||||
// The pre-flight read the source's document (`Item.uneditable`, title: Odd) before the
|
||||
// shape refusal, so the title survives into the thrown error.
|
||||
#expect(error?.operation == .move(title: "Odd"))
|
||||
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card3)") == Item.uneditable)
|
||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == [Ident.card2, "index.md"])
|
||||
}
|
||||
@@ -1407,7 +1413,9 @@ struct BoardWriterCopyTests {
|
||||
)
|
||||
}
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(error?.operation == "copy item")
|
||||
// Same enrichment as the move pre-flight: the root's document (title: Odd) was read
|
||||
// before the uneditable-shape refusal fired.
|
||||
#expect(error?.operation == .copy(title: "Odd"))
|
||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before)
|
||||
}
|
||||
|
||||
@@ -1661,6 +1669,39 @@ struct BoardWriterDeleteRestoreTests {
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping)
|
||||
}
|
||||
|
||||
// MARK: Title enrichment (02-architecture.md § Write-failure surfacing)
|
||||
|
||||
/// `updateIndex`'s pre-flight read succeeds — the shape is readable, only uneditable — so by
|
||||
/// the time the refusal fires, `WriteOperation.withTitle` has already run: the title survives
|
||||
/// into the thrown error. `Fixture.flowMapping` above has no `title` key at all, which is why
|
||||
/// this test reaches for `Item.uneditable` instead — the fixture that actually carries one.
|
||||
@Test func deleteOnAnUneditableItemWithAKnownTitleCarriesItInTheOperation() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let folder = try fixture.item(Ident.lane1, Item.uneditable)
|
||||
|
||||
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
|
||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(error?.operation == .delete(title: "Odd"))
|
||||
}
|
||||
|
||||
/// The negative case: a file that cannot even be read (invalid UTF-8) never gets far enough
|
||||
/// for `readDocument` to hand back a document, so there is no title to learn — the operation
|
||||
/// stays exactly as its call site constructed it, title `nil`.
|
||||
@Test func deleteOnAnUnreadableIndexLeavesTheOperationsTitleNil() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let garbage = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1))
|
||||
let folder = try fixture.item(Ident.lane1, bytes: garbage)
|
||||
|
||||
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
|
||||
guard case .unreadable = error?.reason else {
|
||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||
return
|
||||
}
|
||||
#expect(error?.operation == .delete(title: nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purge
|
||||
@@ -1896,7 +1937,7 @@ struct BoardWriterImportAttachmentsTests {
|
||||
return
|
||||
}
|
||||
#expect(error?.path == missing.path)
|
||||
#expect(error?.operation == "import attachment")
|
||||
#expect(error?.operation == .importAttachment(filename: "missing.png"))
|
||||
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"])
|
||||
}
|
||||
|
||||
|
||||
@@ -131,17 +131,17 @@ struct WriteFidelityMinimalTouchTests {
|
||||
|
||||
try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: "reorder card"
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.order, to: .double(1536)) }
|
||||
}
|
||||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: "set background"
|
||||
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
||||
}
|
||||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: "rename card"
|
||||
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil)
|
||||
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
|
||||
}
|
||||
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||
@@ -308,7 +308,7 @@ struct WriteFidelityUnknownKeyOrderTests {
|
||||
"""
|
||||
let folder = try fixture.item("card", original)
|
||||
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.title, to: .string("After"))
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ struct WriteFidelityCompositeTests {
|
||||
let card3 = try BoardWriter.createCard(inLane: lane1Folder, title: "Third")
|
||||
for (id, body) in [(card1, "First body.\n"), (card2, "Second body.\n"), (card3, "Third body.\n")] {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: "edit body"
|
||||
inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: .style(title: nil)
|
||||
) { $0.body = body }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user