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`. @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(laneID: lane, cardID: card, 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(laneID: lane, cardID: card, title: "Before") ) let two = IntegrityRules.Defect.legacyTombstone( LegacyTombstone(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(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 { 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 ) #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")) } }