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") } } // MARK: - The card level /// **The rule is level-uniform** (01-storage-format.md § Fractal layout ▸ Rules, extended /// 2026-07-29): /// /// > a card's reserved child names are claimed the same way — a regular file or symlink squatting /// > `attachments` (a directory name) displaces by the same ladder (`attachments` → `attachments 2`), /// > so imports, Finder drops, and the sidebar listing never fail one gesture at a time against a /// > squatted name; the displaced file, now an ordinary loose file, rides the next relocation into the /// > real `attachments/` — the heals compose. /// /// The **reserved-but-unconsumed `comments`** is the timing principle's own illustration and is /// deliberately *not* displaced: nothing reads that name until the tracker era, so a wrong-kind holder /// degrades nothing while it stands and keeps the tolerated-stray posture. @MainActor @Suite("Claimed names ▸ the card level") struct CardClaimedNameTests { /// A card holding a *file* called `attachments`. The load reports it and moves nothing — detection /// is read-only at every level. @Test("A file on a card's attachments is reported as a defect, and the load moves nothing") func fileOnAttachmentsIsADefect() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments", Data("not a folder".utf8)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.claimedNameSquatters == [ ClaimedNameSquatter( name: "attachments", found: .file, expected: .directory, location: .card(path: "\(Ident.lane1)/\(Ident.card1)") ), ]) #expect( try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments") == Data("not a folder".utf8), "the loader never writes" ) // A claimed name is not a stray, so it never earns the stray-tolerance vocabulary — and it is // not a loose file either, so the relocation has nothing to say about it yet. #expect(result.warnings.isEmpty) #expect(result.looseCardFiles.isEmpty) } /// A **symlink** wearing the name is the same defect and is moved *as a link*, never followed /// (01 § Fractal layout ▸ Rules: "symlinks are never traversed"). @Test("A symlink on a card's attachments is the same defect") func symlinkOnAttachmentsIsADefect() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try FileManager.default.createSymbolicLink( atPath: fixture.url("\(Ident.lane1)/\(Ident.card1)").appendingPathComponent("attachments").path, withDestinationPath: "../elsewhere" ) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.claimedNameSquatters.map(\.found) == [.symlink]) } /// **`comments` graduated** (2026-07-30, with the comment storage) — the timing principle run /// forwards: the name became load-bearing, so its squatter joined the scheduled class exactly as /// 01 said it would ("a wrong-kind holder is a tolerated stray today and joins the scheduled class /// the day the name becomes load-bearing"). This test used to pin the absence of the defect. @Test("A file on a card's comments is displaced now that the thread consumes the name") func fileOnCommentsIsADefect() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.claimedNameSquatters.map(\.name) == ["comments"]) #expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either") } /// A real `attachments/` folder is a resident, not a squatter — the check is about the node's /// *kind*, and this is the negative case that keeps it honest. @Test("A real attachments folder is no defect at all") func aRealAttachmentsFolderIsFine() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01])) #expect(try BoardLoader.load(boardRoot: fixture.root).claimedNameSquatters.isEmpty) } /// The heal itself, end to end: the ladder renames it inside the **card's** folder, the notice names /// old and new, and the file's bytes are exactly what they were. @Test("The heal displaces it by the ladder, inside the card's own folder") func theHealDisplacesItInsideTheCard() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let cardPath = "\(Ident.lane1)/\(Ident.card1)" try fixture.file("\(cardPath)/attachments", Data("squatter".utf8)) let store = try BoardStore(rootURL: fixture.root) store.displaceClaimedNames() await store.awaitQuiescence() #expect(try fixture.data("\(cardPath)/attachments 2") == Data("squatter".utf8), "preserved verbatim") #expect( IntegrityRules.node(at: fixture.url(cardPath).appendingPathComponent("attachments")) == nil, "and the name is free for the app" ) #expect(store.banners.losses.count == 1) let message = try #require(store.banners.losses.first?.message) #expect(message.contains("attachments")) #expect(message.contains("attachments 2")) } /// **The heals compose** — 01's own word for it: once displaced, the file is an ordinary loose file /// beside the card's `index.md`, which is exactly what the loose-file relocation exists for. One /// reload later it is inside the real `attachments/`. @Test("Displaced, then relocated: the heals compose") func theHealsCompose() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let cardPath = "\(Ident.lane1)/\(Ident.card1)" try fixture.file("\(cardPath)/attachments", Data("squatter".utf8)) let store = try BoardStore(rootURL: fixture.root) store.displaceClaimedNames() await store.awaitQuiescence() // The next load sees an ordinary loose file where the squatter was. let after = try BoardLoader.load(boardRoot: fixture.root) #expect(after.claimedNameSquatters.isEmpty) #expect(after.looseCardFiles.map(\.fileNames) == [["attachments 2"]]) let relocating = try BoardStore(rootURL: fixture.root) relocating.relocateLooseCardFiles() await relocating.awaitQuiescence() #expect( try fixture.data("\(cardPath)/attachments/attachments 2") == Data("squatter".utf8), "and it landed in the real attachments/" ) } /// Two cards squatting the name are **two pieces of work** in one bracket — the signature carries /// the location, so one card's failed heal has no claim to have failed the other's. @Test("Two squatted cards are two defects, healed in one bracket") func twoCardsAreTwoDefects() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments", Data("one".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments", Data("two".utf8)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.claimedNameSquatters.count == 2) #expect(Set(result.defects.flatMap(\.signatures)).count == 2, "distinct work, by location") let store = try BoardStore(rootURL: fixture.root) store.displaceClaimedNames() await store.awaitQuiescence() #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments 2") == Data("one".utf8)) #expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8)) } /// The table is the whole rule — pinned so the level-uniform claim is stated rather than being an /// accident of the probe's implementation. Both card-level names displace since `comments` /// graduated with the comment storage (2026-07-30). @Test("The card-level table claims attachments and comments, and both displace") func theTableStatesTheSplit() { let names = IntegrityRules.claimedCardChildNames #expect(names.map(\.name) == ["attachments", "comments"]) #expect(names.allSatisfy { $0.expected == .directory }) #expect(names.allSatisfy { $0.displacesSquatters }) } }