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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user