Build the integrity service - IntegrityRules and the HealScheduler

The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.

HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.

Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.

69 tests added. 1738 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 15:45:48 -04:00
parent 0d846c634e
commit 3a9db2e78b
27 changed files with 3226 additions and 617 deletions
+60 -16
View File
@@ -11,8 +11,10 @@ import Testing
/// 2. **The decision is pure** write, leave alone, displace, or skip, from what the two claimed
/// board-root names look like on disk and nothing else.
/// 3. **Nothing the user owns is ever destroyed** a markerless `CLAUDE.md` is rescued, a taken
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder is not touched at all, and
/// a current guide is not even opened for writing.
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder wearing the name is
/// *displaced* rather than clobbered (ruled 2026-07-29 the claimed-name rule; it replaced an
/// untouchable-skip, and displacement-never-destruction is what survives), and a current guide is
/// not even opened for writing.
///
/// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are
/// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
@@ -176,10 +178,18 @@ struct AgentGuideDecisionTests {
#expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write)
}
@Test("A symlink or a folder is never touched, free name or not")
func untouchableIsSkipped() {
#expect(AgentGuide.decide(state(.untouchable)) == .skipUntouchable)
#expect(AgentGuide.decide(state(.untouchable, userFileFree: false)) == .skipUntouchable)
/// **Updated 2026-07-29** the claimed-name squatter ruling (01-storage-format.md § Fractal
/// layout Rules) upgraded this case from a skip to a displacement: Lanework owns the board, so
/// a folder or symlink on a name the app claims is an invalid artifact, not a resident. It is
/// moved aside by the Finder ladder and never destroyed.
///
/// **Free name or not is still irrelevant here**, but for a new reason: `CLAUDE.user.md` is the
/// *rescue* destination for user content, and a squatter is not rescued to it it goes to
/// `CLAUDE.md 2`, so the other name's state has no bearing on the decision.
@Test("A symlink or a folder is displaced, free name or not")
func squatterIsDisplaced() {
#expect(AgentGuide.decide(state(.squatted)) == .displaceSquatterThenWrite)
#expect(AgentGuide.decide(state(.squatted, userFileFree: false)) == .displaceSquatterThenWrite)
}
}
@@ -338,10 +348,11 @@ struct AgentGuideStoreTests {
#expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner")
}
/// Symlinks are never followed or touched anywhere in this app (01-storage-format.md § Fractal
/// layout Rules) including one wearing the guide's name.
@Test("A symlinked CLAUDE.md is left as a symlink, and its target is untouched")
func symlinkedGuideIsSkipped() throws {
/// **Updated 2026-07-29** the claimed-name squatter ruling. A symlink wearing the guide's name
/// is still never *followed*: it is moved aside **as a link** (`lstat` semantics all the way
/// down), its target is never opened, and the guide is written on the freed name.
@Test("A symlinked CLAUDE.md is displaced as a link, and its target is untouched")
func symlinkedGuideIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8))
@@ -350,10 +361,20 @@ struct AgentGuideStoreTests {
store.refreshAgentGuide()
let destination = try FileManager.default.destinationOfSymbolicLink(atPath: guideURL(in: fixture).path)
#expect(destination == "elsewhere.md", "the link is still a link")
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8), "and it was not written through")
// The link moved, still a link, still pointing where it pointed never resolved, never
// written through.
// Finder's own splitting: `CLAUDE.md` `CLAUDE 2.md` (the ladder splits after the last
// dot), exactly as `.trash` `.trash 2` for an extension-less name.
let moved = fixture.root.appendingPathComponent("CLAUDE 2.md")
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere.md")
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8))
// And the freed name now carries the guide.
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
// A squatter's displacement is announced the relocation-style warning-tone notice, naming
// old and new. The rescue to CLAUDE.user.md is silent; this is not that.
#expect(!fixture.exists(AgentGuide.userFilename))
#expect(store.banners.losses.count == 1)
#expect(store.banners.losses.first?.message == "Renamed 'CLAUDE.md' to 'CLAUDE 2.md' — Lanework needs that name")
}
/// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken:
@@ -391,8 +412,11 @@ struct AgentGuideStoreTests {
#expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8))
}
@Test("A folder named CLAUDE.md is left alone")
func directoryGuideIsSkipped() throws {
/// **Updated 2026-07-29** the claimed-name squatter ruling: a folder on the guide's name is an
/// invalid artifact, not a resident. It moves aside whole, **contents preserved verbatim**, and
/// the guide takes the freed name.
@Test("A folder named CLAUDE.md is displaced whole, contents intact")
func directoryGuideIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
@@ -400,10 +424,30 @@ struct AgentGuideStoreTests {
store.refreshAgentGuide()
#expect(try fixture.data("\(AgentGuide.filename)/inside.txt") == Data("inside".utf8))
#expect(try fixture.data("CLAUDE 2.md/inside.txt") == Data("inside".utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
// Displacement, never a rescue: `CLAUDE.user.md` is where *user content* goes, and a folder
// on a file's name is not that.
#expect(!fixture.exists(AgentGuide.userFilename))
}
/// The ladder climbs rather than overwriting: a board that already has a `CLAUDE.md 2` gets a
/// `CLAUDE.md 3`, Finder-style, one collision at a time.
@Test("The displacement climbs the Finder ladder past a taken name")
func displacementClimbsTheLadder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
try writeRoot("CLAUDE 2.md", Data("someone else's\n".utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data("CLAUDE 2.md") == Data("someone else's\n".utf8), "untouched")
#expect(try fixture.data("CLAUDE 3.md/inside.txt") == Data("inside".utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write is
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must
/// not spend a banner on a courtesy file.
+35
View File
@@ -224,6 +224,41 @@ struct BoardStoreRegistryTests {
registry.release(store)
}
/// **Every scheduled heal fires at open** (02-architecture.md Components HealScheduler,
/// settled 2026-07-29: "fires uniformly at the reload tail and at registry acquire, closing
/// today's asymmetry where tombstone migration never fires at open").
///
/// Before the engine this seam named two of the three healers by hand, which is how the
/// legacy-tombstone migration came to be the one heal that never fired at open: a board opened,
/// migrated nothing, and waited for an unrelated filesystem event to do what opening should have
/// done. The three defects below are healed by `acquire` alone no watcher event, no reload
/// beyond the one each heal's own write produces.
@Test("Opening a board runs every scheduled heal, migration included")
func acquireRunsEveryScheduledHeal() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// One of each: a legacy tombstone, a loose card file, and a squatter on a claimed name.
try fixture.item(
"\(Ident.lane2)/\(Ident.card3)",
"---\nschema: 1\norder: 1024\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("notes".utf8))
try fixture.file(".trash", Data("squatter".utf8))
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
// The migration the one that used to wait for an unrelated event.
await waitUntil { fixture.exists(".trash/\(Ident.card3)") }
#expect(fixture.exists(".trash/\(Ident.card3)"))
#expect(try !fixture.indexText(".trash/\(Ident.card3)").contains("deleted:"))
// The relocation, the displacement, and the guide the three that already did.
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data("notes".utf8))
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
#expect(fixture.exists(AgentGuide.filename))
}
@Test("Acquiring a root that does not exist throws the loader's own error")
func acquireOfAMissingRootThrows() async throws {
let registry = BoardStoreRegistry()
+16 -6
View File
@@ -60,7 +60,12 @@ private enum Child {
// `Ident` and `Item` live in `WriterTestSupport.swift` shared with `WriteFidelityTests.swift`.
/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical.
private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
/// `kind` joins them as of 2026-07-29: a file without one gains it on the first app write that
/// rewrites it (the integrity service's on-touch heal), which is a key this write is allowed to have
/// touched exactly like the stamps.
private let rewrittenKeys = [
FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.kind,
]
/// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder
/// it materializes, so the file's own content is the only way back to "which card is which".
@@ -567,7 +572,10 @@ struct BoardWriterCreateBoardTests {
#expect(abs(modified.timeIntervalSinceNow) < 60)
}
@Test func keyOrderIsSchemaTitleCreatedModified() throws {
/// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
/// re-ruled 2026-07-29) and goes last, where the common table puts it and where the on-touch
/// backfill appends one on an older file, so a created object and a healed one read the same.
@Test func keyOrderIsSchemaTitleCreatedModifiedKind() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("MyBoard.kanban")
@@ -575,7 +583,8 @@ struct BoardWriterCreateBoardTests {
try BoardWriter.createBoard(at: root, title: "My Board")
let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban"))
#expect(document.keys == ["schema", "title", "created", "modified"])
#expect(document.keys == ["schema", "title", "created", "modified", "kind"])
#expect(document.kind == .valid("board"))
}
@Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws {
@@ -586,7 +595,7 @@ struct BoardWriterCreateBoardTests {
try BoardWriter.createBoard(at: root, title: nil)
let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban"))
#expect(document.keys == ["schema", "created", "modified"])
#expect(document.keys == ["schema", "created", "modified", "kind"])
#expect(!document.contains(FrontmatterKeys.title))
let result = try BoardLoader.load(boardRoot: root)
@@ -656,14 +665,15 @@ struct BoardWriterCreateChildTests {
#expect(first != second)
}
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws {
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModifiedKind() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
#expect(document.keys == ["schema", "title", "order", "created", "modified"])
#expect(document.keys == ["schema", "title", "order", "created", "modified", "kind"])
#expect(document.kind == .valid("lane"))
#expect(document.schema == .valid(1))
#expect(document.modifiedBy == .missing)
}
+289
View File
@@ -0,0 +1,289 @@
import Foundation
import Testing
@testable import Kanban
/// **Claimed-name squatters heal by displacement** (01-storage-format.md § Fractal layout Rules,
/// ruled 2026-07-29 "Lanework owns the board, so an invalid artifact on a claimed name is a defect,
/// not a resident").
///
/// A regular file or symlink squatting `.trash` a directory name is moved aside by the
/// Finder-style rename ladder (`.trash` `.trash 2`), **preserved verbatim, never destroyed**, with
/// the relocation-style warning-tone notice naming old and new. The freed name then serves the app:
/// the *next delete* mints the real `.trash/`, exactly as it does on a board that never had one.
///
/// `CLAUDE.md`'s squatter is the same ruling through the guide's own heal `AgentGuideTests` owns
/// that half.
// MARK: - Fixture
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
return fixture
}
@MainActor
private final class BracketLog {
private(set) var begins = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
}
}
// MARK: - Detection
@Suite("Claimed names ▸ detection")
struct ClaimedNameDetectionTests {
/// Detection is **read-only in the loader**, the Repair precedent: the walk reports, the store
/// acts.
@Test("A file on .trash is reported as a defect, and nothing is moved by the load")
func fileOnTrashIsADefect() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.file(".trash", Data("not a folder".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters == [
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory),
])
// Until the heal lands: the empty-trash read, and the squatter exactly where it was.
#expect(result.model.trash.isEmpty)
#expect(try fixture.data(".trash") == Data("not a folder".utf8))
// A claimed name is not a stray, so it never earns the stray-tolerance vocabulary.
#expect(result.warnings.isEmpty)
}
/// **Symlinks are nodes that are there** `lstat`, never `stat`, so a dangling one counts too.
@Test("A symlink on .trash is a defect, dangling or not")
func symlinkOnTrashIsADefect() throws {
for destination in ["nowhere", "elsewhere"] {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try FileManager.default.createDirectory(
at: fixture.root.appendingPathComponent("elsewhere"),
withIntermediateDirectories: true
)
try FileManager.default.createSymbolicLink(
atPath: fixture.root.appendingPathComponent(".trash").path,
withDestinationPath: destination
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters.map(\.found) == [.symlink])
}
}
@Test("A real .trash directory, or none at all, is no defect")
func healthyTrashIsNoDefect() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(try BoardLoader.load(boardRoot: fixture.root).defects.isEmpty)
try FileManager.default.createDirectory(
at: fixture.root.appendingPathComponent(".trash"),
withIntermediateDirectories: true
)
#expect(try BoardLoader.load(boardRoot: fixture.root).defects.isEmpty)
}
}
// MARK: - The displacement
@MainActor
@Suite("Claimed names ▸ the displacement")
struct ClaimedNameDisplacementTests {
@Test("A file on .trash is moved aside, preserved verbatim, and announced")
func fileIsDisplacedAndAnnounced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".trash", Data("somebody's notes".utf8))
let store = try BoardStore(rootURL: fixture.root)
let brackets = BracketLog()
brackets.attach(to: store)
store.displaceClaimedNames()
// Preserved verbatim under the ladder's name displacement, never destruction.
#expect(try fixture.data(".trash 2") == Data("somebody's notes".utf8))
// The freed name is left *empty*: the next delete mints the real container, exactly as on a
// board that never had one.
#expect(!fixture.exists(".trash"))
#expect(store.banners.losses.map(\.message) == [
"Renamed '.trash' to '.trash 2' — Lanework needs that name",
])
#expect(store.banners.oneShots.isEmpty, "nothing failed")
#expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit")
}
/// A link is displaced **as a link**, never followed: its target is not read, not moved, and not
/// written through.
@Test("A symlink is displaced as a link, its target untouched")
func symlinkIsDisplacedAsALink() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let target = try fixture.file("elsewhere/keep.txt", Data("keep".utf8))
try FileManager.default.createSymbolicLink(
atPath: fixture.root.appendingPathComponent(".trash").path,
withDestinationPath: "elsewhere"
)
let store = try BoardStore(rootURL: fixture.root)
store.displaceClaimedNames()
let moved = fixture.root.appendingPathComponent(".trash 2")
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere")
#expect(try Data(contentsOf: target) == Data("keep".utf8))
#expect(!fixture.exists(".trash"))
}
/// The ladder climbs rather than overwriting Finder's rule, and the same helper the attachment
/// import uses.
@Test("The ladder climbs past a taken name")
func ladderClimbs() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".trash", Data("squatter".utf8))
try fixture.file(".trash 2", Data("already here".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.displaceClaimedNames()
#expect(try fixture.data(".trash 2") == Data("already here".utf8), "untouched")
#expect(try fixture.data(".trash 3") == Data("squatter".utf8))
}
/// **The re-verify** (§ Validation and healing): losing the race to a foreign fix is success,
/// never an error nothing is moved, nothing is said, nothing fails.
@Test("A defect that healed itself under the write is a silent no-op")
func vanishedDefectIsASilentNoOp() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".trash", Data("squatter".utf8))
let store = try BoardStore(rootURL: fixture.root)
// The store's snapshot still carries the defect; disk no longer does.
#expect(store.claimedNameSquatters.count == 1)
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".trash"))
store.displaceClaimedNames()
#expect(!fixture.exists(".trash 2"), "nothing was moved")
#expect(store.banners.losses.isEmpty, "and nothing was claimed to have been")
#expect(store.banners.oneShots.isEmpty)
}
/// The whole point of the timing being *scheduled*: deletion is broken while the squatter
/// stands, and works again on the far side of one heal.
@Test("Deleting works again once the name is freed")
func deleteWorksAfterTheHeal() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".trash", Data("squatter".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.displaceClaimedNames()
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: 1024
)
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
}
/// It rides the engine like every other scheduled heal: deferred under a lock, remembered on a
/// failure, and fired by the reload tail.
@Test("A read-only board defers it")
func lockDefersIt() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(".trash", Data("squatter".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock(.permissionDenied)
store.displaceClaimedNames()
#expect(try fixture.data(".trash") == Data("squatter".utf8))
#expect(!fixture.exists(".trash 2"))
#expect(store.heals.memo(for: .claimedNameSquatted) == nil, "deferred, not remembered")
}
@Test("A reload fires it")
func aReloadFiresIt() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
try fixture.file(".trash", Data("squatter".utf8))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
#expect(store.banners.losses.count == 1)
}
}
// MARK: - Phrasing
@Suite("Claimed names ▸ phrasing")
struct ClaimedNamePhrasingTests {
/// The notice owes **old and new** which file moved, and where to find it.
@Test("One displacement names both names")
func oneDisplacementNamesBoth() {
let message = BannerCenter.displacedClaimedNamesMessage(for: [
BannerCenter.Displacement(name: ".trash", movedTo: ".trash 2"),
])
#expect(message == "Renamed '.trash' to '.trash 2' — Lanework needs that name")
}
@Test("Several fold to a count, the relocation's idiom")
func severalFold() {
let message = BannerCenter.displacedClaimedNamesMessage(for: [
BannerCenter.Displacement(name: ".trash", movedTo: ".trash 2"),
BannerCenter.Displacement(name: "CLAUDE.md", movedTo: "CLAUDE 2.md"),
])
#expect(message == "Renamed 2 items — Lanework needs those names")
}
@Test("Nothing displaced says nothing")
func nothingSaysNothing() {
#expect(BannerCenter.displacedClaimedNamesMessage(for: []) == nil)
}
/// It ranks as a **loss row**: warning tone, user-dismissed, never expiring the relocation's
/// class, because it is the same kind of event (the app moved something of the user's).
@Test("It rides the loss-row class")
@MainActor
func ridesTheLossClass() {
let banners = BannerCenter()
banners.postDisplacedClaimedNames([BannerCenter.Displacement(name: ".trash", movedTo: ".trash 2")])
#expect(banners.losses.count == 1)
#expect(banners.oneShots.isEmpty)
#expect(banners.signposts.isEmpty)
}
/// The failure's mirror, in the one-shot vocabulary the Writer's errors reach the strip through.
@Test("A failed displacement says so")
func failureSaysSo() {
let error = BoardWriteError(
operation: .displaceClaimedName(name: ".trash"),
path: "/b/.trash",
reason: .io(message: "permission denied")
)
// The reason rides as the tail, like every other one-shot's.
#expect(BannerCenter.headline(for: error)
== "Couldn't move '.trash' aside — Lanework needs that name — permission denied")
}
}
+168
View File
@@ -516,3 +516,171 @@ struct EchoLedgerStoreTests {
#expect(log.lines == ["Board changed: 1 lane edited"])
}
}
// MARK: - Heal-marked receipts
/// **The Writer's heal operations drop heal-marked receipts** (06-history-undo.md Commit messages,
/// ruled 2026-07-29: "attribution machinery like the author split, never message tagging").
///
/// **Inert in base beyond the ledger itself**: nothing here reads the flag and nothing renders it
/// it is what pro-m1's committer will read to split a heal's paths into their own commit, and these
/// tests pin the seam it will read from, not a committer that does not exist yet.
@Suite("EchoLedger — heal-marked receipts")
struct EchoLedgerHealMarkTests {
/// An ordinary gesture is not a heal, and says so by default.
@Test("An ordinary write is not heal-marked")
func ordinaryWritesAreNotHeals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
let folder = fixture.url(lane1)
try EchoLedger.$current.withValue(ledger) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
document.set(FrontmatterKeys.width, to: .int(3))
}
}
#expect(!ledger.isHeal(at: folder.appendingPathComponent("index.md")))
}
/// The loose-file relocation an app-initiated heal, so its landed file's receipt is marked.
@Test("The loose-file relocation marks what it moved")
func relocationMarksItsMoves() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
let cardFolder = fixture.url("\(lane1)/\(card1)")
try fixture.file("\(lane1)/\(card1)/notes.txt", Data("notes".utf8))
try EchoLedger.$current.withValue(ledger) {
_ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: cardFolder)
}
#expect(ledger.isHeal(at: cardFolder.appendingPathComponent("attachments/notes.txt")))
}
/// **The import-boundary normalization is not marked**, and the distinction is the design's:
/// an *inline* heal batches with the gesture that triggered it, so its paths belong in that
/// gesture's commit rather than in a heal's own.
@Test("The paste boundary's normalization is not heal-marked")
func inlineNormalizationIsNotAHeal() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
let cardFolder = fixture.url("\(lane1)/\(card1)")
try fixture.file("\(lane1)/\(card1)/notes.txt", Data("notes".utf8))
try EchoLedger.$current.withValue(ledger) {
_ = try BoardWriter.normalizeLooseFiles(inCard: cardFolder)
}
#expect(!ledger.isHeal(at: cardFolder.appendingPathComponent("attachments/notes.txt")))
}
/// The legacy-tombstone migration marks both halves of what it wrote and the ordinary delete
/// it shares a body with does not, because that one is a gesture.
@Test("The tombstone migration marks, the delete beside it does not")
func migrationMarksButDeleteDoesNot() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
try fixture.item(
"\(lane1)/\(Ident.card3)",
"---\nschema: 1\norder: 4096\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try EchoLedger.$current.withValue(ledger) {
_ = try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(lane1)/\(Ident.card3)"),
inBoard: fixture.root,
order: 1024
)
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(lane1)/\(card1)"),
inBoard: fixture.root,
order: 2048
)
}
let migrated = fixture.url(".trash/\(Ident.card3)")
#expect(ledger.isHeal(at: migrated))
#expect(ledger.isHeal(at: migrated.appendingPathComponent("index.md")))
let deleted = fixture.url(".trash/\(card1)")
#expect(!ledger.isHeal(at: deleted))
#expect(!ledger.isHeal(at: deleted.appendingPathComponent("index.md")))
}
/// The agent guide's write, and the claimed-name displacement both app-initiated, both marked.
@Test("The guide write and a displacement are heals")
func guideAndDisplacementAreHeals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
try Data("squatter".utf8).write(to: fixture.root.appendingPathComponent(".trash"))
try EchoLedger.$current.withValue(ledger) {
_ = try BoardWriter.displaceClaimedName(
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory),
atBoardRoot: fixture.root
)
_ = try AgentGuide.install(atBoardRoot: fixture.root)
}
#expect(ledger.isHeal(at: fixture.root.appendingPathComponent(".trash 2")))
#expect(ledger.isHeal(at: fixture.root.appendingPathComponent(AgentGuide.filename)))
}
/// **Supersession drops the mark with the receipt it belonged to**: a later ordinary write to a
/// healed path is exactly the case where the path stops being the heal's alone.
@Test("An ordinary write over a healed path clears the mark")
func supersessionClearsTheMark() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let ledger = EchoLedger()
let index = fixture.url(lane1).appendingPathComponent("index.md")
ledger.recordWrite(at: index, text: "one")
ledger.markHeal(at: index)
#expect(ledger.isHeal(at: index))
ledger.recordWrite(at: index, text: "two")
#expect(!ledger.isHeal(at: index))
#expect(ledger.receipt(at: index) == .content(hash: EchoLedger.hash(of: "two")))
}
/// A mark with no receipt to attach to describes nothing, so it creates nothing.
@Test("Marking an unknown path is a no-op")
func markingAnUnknownPathIsANoOp() {
let ledger = EchoLedger()
ledger.markHeal(atPath: "/nowhere/index.md")
#expect(ledger.receipt(atPath: "/nowhere/index.md") == nil)
#expect(!ledger.isHeal(atPath: "/nowhere/index.md"))
#expect(ledger.outstandingReceipts == 0)
}
/// A move is one fact under two keys, so marking either end marks both the same rule that
/// retires both ends when one is consumed.
@Test("Marking one end of a move marks both")
func markingAMoveMarksBothEnds() {
let ledger = EchoLedger()
ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card")
ledger.markHeal(atPath: "/b/.trash/card")
#expect(ledger.isHeal(atPath: "/b/.trash/card"))
#expect(ledger.isHeal(atPath: "/b/lane/card"))
}
/// The flag changes **nothing** about classification: a heal is an app write like any other, and
/// the render path never consults the ledger at all.
@Test("A heal mark does not change provenance")
func markDoesNotChangeClassification() {
let ledger = EchoLedger()
let index = "/b/lane/index.md"
ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one"))
ledger.markHeal(atPath: index)
#expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "one"))]) == .appMediated)
}
}
+265
View File
@@ -0,0 +1,265 @@
import Foundation
import Testing
@testable import Kanban
/// **The scheduled-heal engine** (02-architecture.md Components HealScheduler;
/// 01-storage-format.md § Validation and healing, settled 2026-07-29) the six-step pattern the
/// three healers used to re-derive one by one, pinned once.
///
/// Each healer's own end-to-end behavior stays where it always lived (`LooseFileRelocationTests`,
/// `TrashWriteTests`, `AgentGuideTests`); what is here is the pattern itself, plus the two places the
/// generalization *changed* behavior on purpose the writability gate, which was only the guide's,
/// and the explicit clear-on-success, which was only the guide's too.
// MARK: - Fixtures
/// A board whose one card has a loose file the shape that gives the engine real work through the
/// simplest healer.
@MainActor
private func makeLooseFileBoard() throws -> (fixture: WriterFixture, cardPath: String) {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
return (fixture, "\(Ident.lane1)/\(Ident.card1)")
}
@MainActor
private final class BracketLog {
private(set) var begins = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
}
}
// MARK: - The gates
@MainActor
@Suite("HealScheduler ▸ the gates")
struct HealSchedulerGateTests {
/// **Step 1** no work is not a failed attempt: an empty signature clears the memo, which is
/// what makes a healthy board forget it ever tried.
@Test("No work rests, and clears the memo")
func noWorkRests() throws {
let board = try makeLooseFileBoard()
defer { board.fixture.tearDown() }
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: board.fixture.root)
store.relocateLooseCardFiles()
#expect(store.heals.memo(for: .looseCardFiles) == nil, "cleared on success")
// Nothing loose left in the snapshot's own reading the resting path.
store.relocateLooseCardFiles()
#expect(store.heals.memo(for: .looseCardFiles) == nil)
}
/// **Step 2, the lock half** defer, never abandon, and remember nothing, so the reload that
/// lifts the lock is the reload that heals.
@Test("A read-only board defers and remembers nothing")
func lockDefersWithoutArming() throws {
let board = try makeLooseFileBoard()
defer { board.fixture.tearDown() }
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: board.fixture.root)
let brackets = BracketLog()
brackets.attach(to: store)
store.enterUnwritableLock(.permissionDenied)
store.relocateLooseCardFiles()
#expect(brackets.begins == 0)
#expect(store.heals.memo(for: .looseCardFiles) == nil, "a refused attempt is not a remembered one")
#expect(board.fixture.exists("\(board.cardPath)/notes.txt"))
}
/// **Step 2, the writability half generalized 2026-07-29.** This `access(2)` check was the
/// agent guide's private defense; it now covers every healer, because it is the only gate that
/// covers the window *between* writability probes. Without it a heal on a root that went
/// read-only mid-session reaches the Writer, fails, and banners about work the user never asked
/// for.
@Test("An unwritable board root is skipped silently, for every healer")
func unwritableRootIsSkippedSilently() throws {
let board = try makeLooseFileBoard()
defer { board.fixture.tearDown() }
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: board.fixture.root)
let brackets = BracketLog()
brackets.attach(to: store)
// Read-only root, with no lock standing the mid-session window the probe cannot see.
try FileManager.default.setAttributes(
[.posixPermissions: 0o500],
ofItemAtPath: board.fixture.root.path
)
defer {
try? FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: board.fixture.root.path
)
}
store.runScheduledHeals()
#expect(brackets.begins == 0, "no write was attempted at all")
#expect(store.banners.oneShots.isEmpty, "and nothing was said about it")
#expect(store.banners.losses.isEmpty)
#expect(store.heals.memo(for: .looseCardFiles) == nil)
}
/// **Steps 3 and 4** armed before the attempt, so a failure is remembered; one failure, one
/// row, then silence until the picture on disk actually changes.
@Test("A failure arms the memo and is not retried")
func failureArmsTheMemo() throws {
let board = try makeLooseFileBoard()
defer { board.fixture.tearDown() }
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: board.fixture.root)
let brackets = BracketLog()
brackets.attach(to: store)
// The card folder is writable but its `attachments/` cannot be created: the relocation's
// write half fails, with the defect still on disk afterwards.
try FileManager.default.setAttributes(
[.posixPermissions: 0o500],
ofItemAtPath: board.fixture.url(board.cardPath).path
)
defer {
try? FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: board.fixture.url(board.cardPath).path
)
}
store.relocateLooseCardFiles()
let armed = store.heals.memo(for: .looseCardFiles)
store.relocateLooseCardFiles()
#expect(armed != nil, "armed before the attempt, so the throw leaves it set")
#expect(store.heals.memo(for: .looseCardFiles) == armed)
#expect(brackets.begins == 1, "the second call was refused by the memo, not attempted")
#expect(store.banners.oneShots.count == 1, "one failure, one row")
}
/// **Step 6, generalized 2026-07-29** the memo clears *explicitly* on success, where before
/// only the guide did this and the others merely happened to converge because the next walk
/// found no work. It matters when the same defect comes back: a foreign undo restores the exact
/// picture the heal just fixed, and a standing memo would make that the one thing the self-heal
/// could not heal.
@Test("The same picture heals again after a success")
func theSamePictureHealsAgain() throws {
let board = try makeLooseFileBoard()
defer { board.fixture.tearDown() }
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: board.fixture.root)
store.relocateLooseCardFiles()
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(store.heals.memo(for: .looseCardFiles) == nil)
// Somebody puts it back, byte for byte the same defect signature as before.
try FileManager.default.removeItem(at: board.fixture.url("\(board.cardPath)/attachments"))
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
store.relocateLooseCardFiles()
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
}
/// The memos are per class, so one heal's failure never silences another's work.
@Test("Memos are per defect class")
func memosArePerClass() throws {
let board = try makeLooseFileBoard()
defer { board.fixture.tearDown() }
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: board.fixture.root)
store.runScheduledHeals()
// The guide wrote (its class rests cleared), the relocation ran (so does its own), and
// neither class's memo is holding the other's picture.
#expect(store.heals.memo(for: .looseCardFiles) == nil)
#expect(store.heals.memo(for: .staleAgentGuide) == nil)
#expect(store.heals.memo(for: .legacyTombstone) == nil)
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(board.fixture.exists(AgentGuide.filename))
}
}
// MARK: - The inline renumber-and-retry
@Suite("HealScheduler ▸ the renumber two-step")
struct HealSchedulerRenumberTests {
/// A ladder with room answers on the **first** ask no compaction, and the ladder comes back
/// exactly as it went in.
@Test("A usable ladder never renumbers")
func usableLadderNeverRenumbers() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
let placed = try #require(try HealScheduler.placingRanks(
amongVisible: [1024, 2048],
compacting: fixture.url(Ident.lane1),
{ Ranks.insertionRank(amongVisible: $0, at: 1) }
))
#expect(placed.placement == 1536)
#expect(placed.ladder == [1024, 2048])
#expect(!placed.renumbered)
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == before, "nothing was rewritten")
}
/// Exhausted precision compacts the parent's visible children and asks again **against the
/// fresh ladder**, which is what the caller then has to place among.
@Test("Exhausted precision compacts and answers against the fresh ladder")
func exhaustedPrecisionCompacts() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
// Adjacent doubles: no representable midpoint between them.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1", title: "A"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000000000000002", title: "B"))
let orders = try [Ident.card1, Ident.card2].map {
try #require(FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order.value)
}
let placed = try #require(try HealScheduler.placingRanks(
amongVisible: orders,
compacting: fixture.url(Ident.lane1),
{ Ranks.insertionRank(amongVisible: $0, at: 1) }
))
#expect(placed.renumbered)
#expect(placed.ladder == [1024, 2048])
#expect(placed.placement == 1536)
// The compaction really landed on disk the two cards now hold the fresh ladder.
let after = try [Ident.card1, Ident.card2].map {
try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
}
#expect(after == [.valid(1024), .valid(2048)])
}
/// A compacted ladder that still has no answer is "write nothing" every call site's own
/// posture, spelled once.
@Test("No answer after compacting is nil")
func noAnswerAfterCompactingIsNil() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let placed = try HealScheduler.placingRanks(
amongVisible: [],
compacting: fixture.url(Ident.lane1),
{ (_: [Double]) -> Double? in nil }
)
#expect(placed == nil)
}
}
+4 -1
View File
@@ -68,7 +68,10 @@ private let card3 = ItemID(rawValue: Ident.card3)
/// through a rename byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("title:")
// `kind:` joins the filtered keys as of 2026-07-29: a file without one gains it on the
// first app write that rewrites it the integrity service's on-touch heal, riding this
// write's own atomic rewrite (01-storage-format.md § Validation and healing).
!$0.hasPrefix("modified") && !$0.hasPrefix("title:") && !$0.hasPrefix("kind:")
}
}
+500
View File
@@ -0,0 +1,500 @@
import Foundation
import Testing
@testable import Kanban
/// **The integrity service's rules** (01-storage-format.md § Validation and healing, settled
/// 2026-07-29; 02-architecture.md Components IntegrityRules): the one vocabulary of object
/// validity, pinned as the pure functions it is.
///
/// The *scheduling* of heals lives next door (`HealSchedulerTests`), and each heal's own end-to-end
/// behavior stays in the suite that always owned it (`LooseFileRelocationTests`, `TrashStorageTests`,
/// `AgentGuideTests`). What is here is the consolidation itself: that the identity predicate, the
/// canonical form, the reserved-name tables, per-kind validation, the trash discriminator, the
/// on-touch heals and the defect vocabulary each have exactly one implementation and behave as the
/// design states them.
// MARK: - The identity predicate and its canonical form
@Suite("IntegrityRules ▸ identity")
struct IntegrityIdentityTests {
/// Shape-only: hex, `8-4-4-4-12`, **any case, any version** lowercase v4 is the app's emission
/// rule, not the gate.
@Test("The predicate is shape-only — any case, any version")
func predicateIsShapeOnly() {
#expect(IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-111111111111"))
#expect(IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-111111111111".uppercased()))
// v7's version nibble, and a variant nibble RFC 4122 would reject both accepted.
#expect(IntegrityRules.isIdentityShaped("0195a3f0-0000-7000-0000-000000000000"))
#expect(!IntegrityRules.isIdentityShaped("notes"))
#expect(!IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-11111111111"))
#expect(!IntegrityRules.isIdentityShaped("11111111111141118111111111111111"))
#expect(!IntegrityRules.isIdentityShaped("gggggggg-1111-4111-8111-111111111111"))
}
/// **One predicate, one implementation** the loader's spelling forwards to it, which is what
/// "no parallel derivation" means in practice.
@Test("The loader's gate is this predicate")
func loaderSharesThePredicate() {
for name in ["11111111-1111-4111-8111-111111111111", "NOTES", "", ".trash"] {
#expect(BoardLoader.isUUIDShaped(name) == IntegrityRules.isIdentityShaped(name))
}
}
/// **The fold** (settled 2026-07-29): `ItemID`'s equality and the Writer's string-level checks
/// canonicalize through the same function. Before it, the Writer carried a private copy one
/// line, and one line too many for a rule that decides whether two folders are the same item.
@Test("ItemID's equality is this canonical form")
func itemIDSharesTheCanonicalForm() {
let lower = "55555555-5555-4555-8555-555555555555"
#expect(IntegrityRules.canonicalIdentity(lower.uppercased()) == lower)
#expect(ItemID(rawValue: lower) == ItemID(rawValue: lower.uppercased()))
#expect(ItemID(rawValue: lower).hashValue == ItemID(rawValue: lower.uppercased()).hashValue)
// rawValue still round-trips byte-perfect canonicalization is for *comparing*, never for
// storing.
#expect(ItemID(rawValue: lower.uppercased()).rawValue == lower.uppercased())
}
/// Total on anything: an off-shape name compares by its own lowercasing, the harmless reading.
@Test("The canonical form is total")
func canonicalFormIsTotal() {
#expect(IntegrityRules.canonicalIdentity("Notes") == "notes")
#expect(IntegrityRules.canonicalIdentity("") == "")
}
}
// MARK: - The reserved-name tables
@Suite("IntegrityRules ▸ reserved names")
struct IntegrityReservedNameTests {
/// One table, read by the loader under its own names.
@Test("The tables are the loader's")
func tablesAreShared() {
#expect(BoardLoader.reservedCardChildNames == IntegrityRules.reservedCardChildNames)
#expect(BoardLoader.reservedRootNames == IntegrityRules.claimedRootNameSet)
#expect(BoardLoader.trashFolderName == IntegrityRules.trashFolderName)
#expect(BoardWriter.attachmentsFolderName == IntegrityRules.attachmentsFolderName)
}
/// The claimed names carry **what kind of node each may be** the fact the displacement heal
/// turns on, and the reason the table is a list of values rather than a `Set<String>`.
@Test("Each claimed name declares its node kind and whether it displaces")
func claimedNamesDeclareTheirKind() throws {
let trash = try #require(IntegrityRules.claimedRootNames.first { $0.name == ".trash" })
#expect(trash.expected == .directory)
#expect(trash.displacesSquatters)
let guide = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.md" })
#expect(guide.expected == .file)
#expect(guide.displacesSquatters)
// The standing exception: a rescue *destination* is never itself freed by a second
// displacement, which would cascade renames (08-agent-integration.md Ownership).
let userFile = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.user.md" })
#expect(!userFile.displacesSquatters)
// Seeded once, then the user's to edit (06-history-undo.md Repository hygiene).
let gitignore = try #require(IntegrityRules.claimedRootNames.first { $0.name == ".gitignore" })
#expect(!gitignore.displacesSquatters)
}
/// `lstat` semantics: a **dangling** symlink is a node that is there.
@Test("The node probe never follows a link")
func nodeProbeUsesLstat() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("IntegrityRulesTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("x".utf8).write(to: root.appendingPathComponent("file.txt"))
try FileManager.default.createDirectory(at: root.appendingPathComponent("dir"), withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(
atPath: root.appendingPathComponent("dangling").path,
withDestinationPath: "nowhere"
)
try FileManager.default.createSymbolicLink(
atPath: root.appendingPathComponent("toFile").path,
withDestinationPath: "file.txt"
)
#expect(IntegrityRules.node(at: root.appendingPathComponent("file.txt")) == .file)
#expect(IntegrityRules.node(at: root.appendingPathComponent("dir")) == .directory)
#expect(IntegrityRules.node(at: root.appendingPathComponent("dangling")) == .symlink)
#expect(IntegrityRules.node(at: root.appendingPathComponent("toFile")) == .symlink)
#expect(IntegrityRules.node(at: root.appendingPathComponent("absent")) == nil)
}
}
// MARK: - Placement and the trash discriminator
@Suite("IntegrityRules ▸ kind")
struct IntegrityKindTests {
/// "Level is position", as two names and nothing else.
@Test("Placement reads position")
func placementReadsPosition() {
let uuid = "11111111-1111-4111-8111-111111111111"
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: uuid) == .card)
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "MyBoard.kanban") == .lane)
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash)
// A board root's folder name is a Finder document name, so position cannot answer for it
// and neither can it for a hand-named folder. "Unknown" rather than "board" is what keeps a
// guessed kind off disk.
#expect(IntegrityRules.placement(ofFolderNamed: "MyBoard.kanban", inParentNamed: "Documents") == .unknown)
#expect(IntegrityRules.placement(ofFolderNamed: "notes", inParentNamed: "MyBoard.kanban") == .unknown)
}
/// **The value is trusted outright** no corroboration, no policing (re-ruled 2026-07-29).
@Test("The trash discriminator trusts the value")
func trashDiscriminatorTrustsTheValue() {
// Honored even against the shape: a card-shaped folder saying `lane` is a lane.
#expect(IntegrityRules.trashKind(kindValue: "lane", hasIdentityShapedChildIndex: false) == .lane)
// And a lane-shaped folder saying `card` is a card.
#expect(IntegrityRules.trashKind(kindValue: "card", hasIdentityShapedChildIndex: true) == .card)
}
/// An unrecognized value, or none, falls through to **shape**.
@Test("An unrecognized value falls through to shape")
func unrecognizedValueFallsToShape() {
#expect(IntegrityRules.trashKind(kindValue: nil, hasIdentityShapedChildIndex: true) == .lane)
#expect(IntegrityRules.trashKind(kindValue: nil, hasIdentityShapedChildIndex: false) == .card)
#expect(IntegrityRules.trashKind(kindValue: "widget", hasIdentityShapedChildIndex: true) == .lane)
#expect(IntegrityRules.trashKind(kindValue: "", hasIdentityShapedChildIndex: false) == .card)
// `board` is not a third answer in the trash a board cannot be trashed, so shape decides.
#expect(IntegrityRules.trashKind(kindValue: "board", hasIdentityShapedChildIndex: true) == .lane)
}
}
// MARK: - Per-kind validation
@Suite("IntegrityRules ▸ validation")
struct IntegrityValidationTests {
private func bytes(_ text: String) -> Data { Data(text.utf8) }
/// The per-kind field table: `order` is required on lanes and cards, **never** on the board.
@Test("Order is required per kind")
func orderIsRequiredPerKind() {
#expect(!IntegrityRules.requiresOrder(.board))
#expect(IntegrityRules.requiresOrder(.lane))
#expect(IntegrityRules.requiresOrder(.card))
}
@Test("A board index validates without an order")
func boardValidatesWithoutOrder() throws {
let document = try IntegrityRules.validateIndex(
bytes("---\nschema: 1\ntitle: Board\n---\nbody\n"),
path: "index.md",
kind: .board,
supportedSchema: 1
)
#expect(document.title == .valid("Board"))
}
@Test("A lane or card index without an order is refused")
func laneAndCardRequireOrder() {
for kind in [IntegrityRules.ObjectKind.lane, .card] {
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
try IntegrityRules.validateIndex(
bytes("---\nschema: 1\n---\nbody\n"),
path: "index.md",
kind: kind,
supportedSchema: 1
)
}
}
}
/// The card window's gate is this rule at `kind: .card` one function, not a copy.
@Test("validateCardIndex is validateIndex at card")
func cardValidatorIsTheGeneralOne() {
let missingOrder = bytes("---\nschema: 1\n---\nbody\n")
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
try BoardLoader.validateCardIndex(missingOrder, path: "index.md")
}
let newer = bytes("---\nschema: 99\norder: 1\n---\n")
#expect(throws: BoardLoadError(path: "index.md", reason: .schemaNewerThanApp(found: 99))) {
try BoardLoader.validateCardIndex(newer, path: "index.md")
}
}
/// The refuse-writes verdict's rule, named in the vocabulary rather than left as a property one
/// call site happens to read.
@Test("The uneditable shape is the document's, named here")
func uneditableShapeIsNamed() throws {
let flow = try FrontmatterDocument.parse("---\n{schema: 1, order: 1024}\n---\nbody\n")
#expect(IntegrityRules.uneditableShape(of: flow) == .keyWithoutOwnLine)
let ordinary = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\n---\nbody\n")
#expect(IntegrityRules.uneditableShape(of: ordinary) == nil)
}
}
// MARK: - On-touch heals
@Suite("IntegrityRules ▸ on-touch heals")
struct IntegrityOnTouchTests {
@Test("A missing kind backfills with the object's own kind")
func missingKindBackfills() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\n---\nbody\n")
let heals = IntegrityRules.healOnTouch(&document, kind: .card)
#expect(heals == [.kindBackfilled(.card)])
#expect(document.kind == .valid("card"))
// Appended before the closing delimiter, last where the common table puts it.
#expect(document.keys == ["schema", "order", "kind"])
#expect(document.body == "body\n")
}
/// **The value is never rewritten, never corroborated, never stripped** consumers trust it.
@Test("A present kind is left exactly as written")
func presentKindIsLeftAlone() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind: widget\n---\n")
#expect(IntegrityRules.healOnTouch(&document, kind: .card).isEmpty)
#expect(document.kind == .valid("widget"))
// Even a value that contradicts position: the trash reader honors it, so the writer must not
// "correct" it out from under whoever wrote it.
var lane = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind: lane\n---\n")
#expect(IntegrityRules.healOnTouch(&lane, kind: .card).isEmpty)
#expect(lane.kind == .valid("lane"))
}
/// Null-as-missing, the engine's own rule, applied here: a key started and never given a value
/// is absent, and so backfills.
@Test("An explicit null backfills")
func explicitNullBackfills() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind:\n---\n")
#expect(IntegrityRules.healOnTouch(&document, kind: .lane) == [.kindBackfilled(.lane)])
#expect(document.kind == .valid("lane"))
}
/// **A guessed kind is worse than an absent one**: where position cannot answer, nothing is
/// stamped.
@Test("No kind, no stamp")
func unknownKindStampsNothing() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\n---\n")
#expect(IntegrityRules.healOnTouch(&document, kind: nil).isEmpty)
#expect(document.kind == .missing)
}
}
// MARK: - The defect vocabulary
@Suite("IntegrityRules ▸ defects")
struct IntegrityDefectTests {
private let lane = ItemID(rawValue: "11111111-1111-4111-8111-111111111111")
private let card = ItemID(rawValue: "55555555-5555-4555-8555-555555555555")
/// Each defect knows its heal class the memo key and the banner-posture row.
@Test("Every defect names its class")
func defectsNameTheirClass() {
#expect(IntegrityRules.Defect.looseCardFiles(
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
).healClass == .looseCardFiles)
#expect(IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil)
).healClass == .legacyTombstone)
#expect(IntegrityRules.Defect.claimedNameSquatted(
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
).healClass == .claimedNameSquatted)
}
/// **One signature per file for the loose-file defect**, so a card that gains or loses a single
/// loose file is a different picture and earns a fresh attempt.
@Test("A loose-file defect signs per file")
func looseFilesSignPerFile() {
let defect = IntegrityRules.Defect.looseCardFiles(
LooseCardFiles(laneID: lane, cardID: card, title: "T", fileNames: ["a.txt", "b.txt"])
)
#expect(defect.signatures.count == 2)
#expect(Set(defect.signatures) == [
"loose:\(lane.rawValue)/\(card.rawValue)/a.txt",
"loose:\(lane.rawValue)/\(card.rawValue)/b.txt",
])
}
/// The title is *not* in the signature: renaming a card does not make its pending relocation a
/// new defect to retry.
@Test("Signatures identify the work, not its display")
func signaturesIdentifyTheWork() {
let one = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "Before")
)
let two = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "After")
)
#expect(one.signatures == two.signatures)
}
/// The classes are disjoint by construction a memo for one heal can never collide with
/// another's picture.
@Test("Signatures are namespaced per class")
func signaturesAreNamespaced() {
let loose = IntegrityRules.Defect.looseCardFiles(
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
)
let tombstone = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: nil)
)
let squatter = IntegrityRules.Defect.claimedNameSquatted(
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
)
let all = Set(loose.signatures + tombstone.signatures + squatter.signatures)
#expect(all.count == 3)
#expect(squatter.signatures == ["claimed:.trash:symlink"])
}
}
// MARK: - The `kind` key, through the Writer
/// **`kind` is written at creation of every object and backfills on touch** (01-storage-format.md
/// § Frontmatter, re-ruled 2026-07-29) the two halves of the rule, at the seam that implements
/// them (`BoardWriter.createBoard`/`createLane`/`createCard`, and `BoardWriter.updateIndex`).
///
/// **On-touch only, never a scheduled sweep**: nothing in the app walks a board adding this key, and
/// these tests are written so that a future sweep would fail them (an untouched sibling keeps no
/// `kind` at all).
@Suite("The kind key ▸ stamping and backfill")
struct ObjectKindWriteTests {
private func kind(of relativePath: String, in fixture: WriterFixture) throws -> FieldValue<String> {
try FrontmatterDocument.parse(fixture.indexText(relativePath)).kind
}
@Test("Every create path stamps its own kind")
func createPathsStamp() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("MyBoard.kanban")
try BoardWriter.createBoard(at: root, title: "Board")
let lane = try BoardWriter.createLane(inBoard: root, title: "Todo")
let card = try BoardWriter.createCard(
inLane: root.appendingPathComponent(lane.rawValue, isDirectory: true),
title: "Fix login"
)
#expect(try kind(of: "MyBoard.kanban", in: fixture) == .valid("board"))
#expect(try kind(of: "MyBoard.kanban/\(lane.rawValue)", in: fixture) == .valid("lane"))
#expect(try kind(of: "MyBoard.kanban/\(lane.rawValue)/\(card.rawValue)", in: fixture) == .valid("card"))
}
/// The backfill reads **position** a lane's child is a card, a board root's child is a lane
/// so an older file gains the *right* value without the caller being asked for one.
@Test("A rewrite backfills a missing kind from position")
func rewriteBackfillsFromPosition() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
try BoardWriter.updateIndex(
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
operation: .style(title: nil)
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
try BoardWriter.updateIndex(
inItemFolder: fixture.url(Ident.lane1),
operation: .style(title: nil)
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
// **On touch only**: the sibling nobody wrote to still has no `kind`, which is exactly what
// "never a scheduled backfill sweep" means on disk.
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card2)", in: fixture) == .missing)
#expect(try kind(of: "", in: fixture) == .missing)
}
/// The board root is the one file whose kind position cannot answer, so its writers **declare**
/// it and the declaration is what the backfill uses.
@Test("The board root's kind is declared by its writers")
func boardRootKindIsDeclared() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try BoardWriter.updateIndex(inItemFolder: fixture.root, kind: .board, operation: .rename(title: nil)) {
$0.set(FrontmatterKeys.title, to: .string("Renamed"))
}
#expect(try kind(of: "", in: fixture) == .valid("board"))
}
/// Undeclared and unanswerable by position **nothing is stamped**. A guessed kind on disk
/// would be worse than an absent one, because the trash's discriminator trusts what it finds.
@Test("A hand-named folder is stamped with nothing")
func unknownPositionStampsNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
$0.set(FrontmatterKeys.background, to: .string("fern"))
}
#expect(try kind(of: "notes", in: fixture) == .missing)
}
/// Inside `.trash/` position cannot answer either, so **shape** does the same rule the trash
/// reader uses, so a backfilled value and a read value can never disagree.
@Test("A trashed folder backfills by shape")
func trashBackfillsByShape() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "A trashed card"))
try fixture.item(".trash/\(Ident.lane2)", Item.rich(order: "2048", title: "A trashed lane"))
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Its card"))
for path in [".trash/\(Ident.card1)", ".trash/\(Ident.lane2)"] {
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .reorder(title: nil)) {
$0.set(FrontmatterKeys.order, to: .double(4096))
}
}
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
#expect(try kind(of: ".trash/\(Ident.lane2)", in: fixture) == .valid("lane"))
}
/// **The trash move's own rank mint is a touch** 01's own example of where the key earns its
/// keep ("any Writer rewrite of that lane's `index.md`, the trash move's rank mint included").
@Test("Deleting a card backfills its kind on the way into the trash")
func deleteBackfillsOnTheWayIn() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: 1024
)
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
}
/// Templates gain the key **lazily**, through this same backfill there is no template
/// migration, by design.
@Test("A copy carries what the source had, and heals what it did not")
func copiesInheritAndHeal() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
let copy = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane1),
order: 2048,
stamps: .fork
)
// The copy's own `index.md` is rewritten by the copy (stamps, order), so it heals in flight.
#expect(try kind(of: "\(Ident.lane1)/\(copy.rawValue)", in: fixture) == .valid("card"))
}
}
+2 -1
View File
@@ -47,7 +47,8 @@ private func makeBoard() throws -> WriterFixture {
/// through a resize byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("width:")
// `kind:` the on-touch backfill rides every app rewrite of a file that lacks one.
!$0.hasPrefix("modified") && !$0.hasPrefix("width:") && !$0.hasPrefix("kind:")
}
}
+2
View File
@@ -74,7 +74,9 @@ private let card4 = ItemID(rawValue: Ident.card4)
/// survives the filter it is not `icon:`, and the app offers no control for it.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
// `kind:` the on-touch backfill rides every app rewrite of a file that lacks one.
!$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
&& !$0.hasPrefix("kind:")
}
}
+102
View File
@@ -840,3 +840,105 @@ struct TrashIdentityTests {
#expect(try fixture.indexText("destination/.trash/\(Ident.card1)").contains("title: Trashed twin"))
}
}
// MARK: - The trash's kind discriminator
/// **`kind:` discriminates inside `.trash/`** (01-storage-format.md § Deletion, re-ruled
/// 2026-07-29): depth defines meaning on the live board, but the trash is flat, and an empty lane
/// folder is shape-identical to a card folder. The reader **trusts the value**, and only an
/// unrecognized value or no key at all falls through to shape.
///
/// The verdict rides `LoadResult.trashKinds` a *reading*, not a rendering: every entry still
/// parses through the one card parse (a trashed card is "an ordinary card in a special place"), and
/// nothing is hidden or dropped on account of its kind.
@Suite("BoardLoader ▸ the trash's kind discriminator")
struct TrashKindDiscriminatorTests {
@Test("kind: card is honored even against the shape")
func cardValueBeatsShape() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
let child = uuidName()
try fixture.index("", "schema: 1\n")
// Lane-shaped on disk a UUID-named child with its own index.md and yet it says card.
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: card\n")
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
// Trusting the value is not policing it: the entry still loads, and its child is still not a
// level (the walk stops at a trash entry exactly as it stops at a card).
#expect(result.model.trash.map(\.id.rawValue) == [entry])
}
@Test("kind: lane is honored even against the shape")
func laneValueBeatsShape() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
try fixture.index("", "schema: 1\n")
// Card-shaped on disk no children at all and yet it says lane. An external writer's
// `kind: lane` is honored, never policed.
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
}
/// No key, or a value outside the schema's three, falls through to shape UUID-shaped children
/// with their own `index.md` lane, else card.
@Test("An unrecognized value or no key falls through to shape")
func unrecognizedFallsToShape() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let bare = uuidName()
let laneShaped = uuidName()
let child = uuidName()
let odd = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(bare)", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(laneShaped)", "schema: 1\norder: 2048\n")
try fixture.index(".trash/\(laneShaped)/\(child)", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(odd)", "schema: 1\norder: 3072\nkind: widget\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: bare)] == .card)
#expect(result.trashKinds[ItemID(rawValue: laneShaped)] == .lane)
#expect(result.trashKinds[ItemID(rawValue: odd)] == .card, "unrecognized, and card-shaped")
// The unrecognized value is preserved verbatim never corrected, never stripped.
#expect(result.model.trash.first { $0.id.rawValue == odd }?.document.kind == .valid("widget"))
}
/// A folder whose `kind` is *shape-derived* today keeps that reading only until it is touched
/// at which point the backfill writes the same answer down. The two rules are one function, so
/// the read and the write can never disagree.
@Test("The reading a shape produces is the value the backfill writes")
func shapeReadingMatchesTheBackfill() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
let child = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\n")
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
let before = try BoardLoader.load(boardRoot: fixture.root)
#expect(before.trashKinds[ItemID(rawValue: entry)] == .lane)
try BoardWriter.updateIndex(
inItemFolder: fixture.root.appendingPathComponent(".trash/\(entry)"),
operation: .reorder(title: nil)
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
let after = try BoardLoader.load(boardRoot: fixture.root)
#expect(after.model.trash.first?.document.kind == .valid("lane"))
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
}
}
+2 -1
View File
@@ -87,7 +87,8 @@ private let newer = ItemID(rawValue: More.newer)
/// through a delete byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("order:")
// `kind:` the on-touch backfill rides every app rewrite of a file that lacks one.
!$0.hasPrefix("modified") && !$0.hasPrefix("order:") && !$0.hasPrefix("kind:")
}
}
+3 -1
View File
@@ -105,7 +105,9 @@ private func document(_ fixture: WriterFixture, _ relativePath: String) throws -
/// byte-identical, unknown keys and their comments included.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified")
// `kind:` the on-touch backfill rides every app rewrite of a file that lacks one, so a
// round trip lands the same bytes *plus* the backfilled key, which is not the undo's doing.
!$0.hasPrefix("modified") && !$0.hasPrefix("kind:")
}
}