The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
463 lines
20 KiB
Swift
463 lines
20 KiB
Swift
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
|
|
)
|
|
|
|
#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 })
|
|
}
|
|
}
|