From 31f7691062793605b93b613e0711e72e3f4b56a6 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 16:03:39 -0400 Subject: [PATCH] Gate level detection on UUID folder-name shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only lowercase-hex 8-4-4-4-12 folder names are lane/card candidates; anything else is a stray — skipped with a distinct warning, never descended, never able to fail-fast a load. UUID-shaped folders keep the prior contract (missing index skips, malformed frontmatter fail-fasts). Design resolution from the Redesign board. +5 tests. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/BoardLoader.swift | 62 ++++++- Kanban/Storage/BoardModel.swift | 11 +- KanbanTests/BoardLoaderTests.swift | 257 ++++++++++++++++++++++------- 3 files changed, 263 insertions(+), 67 deletions(-) diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 01124e4..c50c5ce 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -8,13 +8,21 @@ import os /// partial result. /// /// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders → -/// cards. Any non-reserved directory containing `index.md` at those depths is a level -/// regardless of its name — no UUID-shape filtering, no name-based gating. +/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸ +/// Rules): only a folder whose name has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — is a +/// lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly what's checked. +/// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped +/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended +/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a +/// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older +/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast. /// /// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card* /// (01-storage-format.md § Fractal layout ▸ Rules); since cards are leaves here — this loader /// never scans a card folder's contents beyond checking for `index.md` — that reservation is -/// satisfied by construction and needs no explicit filtering. +/// satisfied by construction and needs no explicit filtering. Doubly so under the shape rule: +/// were a card folder ever scanned, `attachments` and `comments` are non-UUID-shaped and would +/// read as strays, not levels — so they never need special-casing against the stray warning. /// /// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never /// followed, whether it points to a file or a directory — this loader does not resolve @@ -62,6 +70,10 @@ public enum BoardLoader: Sendable { var lanes: [Lane] = [] for laneURL in try directoryCandidates(in: boardRoot) { let laneName = laneURL.lastPathComponent + guard isUUIDShaped(laneName) else { + warn(.nonUUIDFolderIgnored(path: laneName)) + continue + } guard hasIndex(laneURL) else { warn(.missingIndex(path: laneName)) continue @@ -76,6 +88,10 @@ public enum BoardLoader: Sendable { for cardURL in try directoryCandidates(in: laneURL) { let cardName = cardURL.lastPathComponent let cardRelPath = laneName + "/" + cardName + guard isUUIDShaped(cardName) else { + warn(.nonUUIDFolderIgnored(path: cardRelPath)) + continue + } guard hasIndex(cardURL) else { warn(.missingIndex(path: cardRelPath)) continue @@ -155,10 +171,31 @@ public enum BoardLoader: Sendable { FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path) } + /// The lowercase hex characters `isUUIDShaped` accepts in each `-`-delimited group. + private static let lowercaseHexDigits = Set("0123456789abcdef") + + /// Whether `name` has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — gating lane/card level + /// detection (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level + /// detection"). Deliberately permissive about *which* nibbles matter: the version (13th hex + /// digit) and variant (17th hex digit) are **not** validated, so any lowercase-hex string in + /// this shape reads as a candidate — whether or not it was actually minted by + /// `UUID().uuidString.lowercased()`. That reading is intentional, not an oversight: the + /// loader's job is recognizing the folder-naming *convention*, not re-deriving RFC 4122 + /// conformance every load. Case-sensitive — an uppercase or mixed-case UUID string is a + /// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name + /// (`BoardModel.swift`). + private static func isUUIDShaped(_ name: String) -> Bool { + let groups = name.split(separator: "-", omittingEmptySubsequences: false) + guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false } + return groups.allSatisfy { $0.allSatisfy(lowercaseHexDigits.contains) } + } + /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding /// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray /// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded - /// here too: only directories are level candidates. + /// here too: only directories are level candidates at all, and the caller further narrows + /// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing + /// anything else with them. /// /// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no /// candidates" rather than failing the whole load — fail-fast is reserved for the board @@ -243,11 +280,20 @@ public struct LoadResult: Sendable { /// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError` /// for what does. public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { - /// A folder below the board root has no `index.md` — skipped, not fail-fast (an - /// interrupted two-step create must not brick the board). `path` is relative to the board - /// root. + /// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not + /// fail-fast (an interrupted two-step create must not brick the board). `path` is relative + /// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a + /// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never + /// this case. case missingIndex(path: String) + /// A lane/card-depth folder whose name doesn't have UUIDv4's shape (`isUUIDShaped`) — + /// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one, + /// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level + /// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the + /// board root. + case nonUUIDFolderIgnored(path: String) + /// A board-level `deleted:` key is legal per the frontmatter table but meaningless /// (01-storage-format.md § Deletion) — ignored, never tombstones the board. case boardLevelDeletedIgnored @@ -256,6 +302,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { switch self { case let .missingIndex(path): "\(path): folder has no index.md, skipped" + case let .nonUUIDFolderIgnored(path): + "\(path): folder name is not UUID-shaped, ignored as a stray" case .boardLevelDeletedIgnored: "index.md: board-level 'deleted' key is meaningless, ignored" } diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index 3114e82..8c9016b 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -9,10 +9,13 @@ import Foundation /// The immutable identity of a lane or card folder: its exact name, byte-for-byte. /// -/// Folder names are lowercase UUIDv4 by convention (01-storage-format.md § Fractal layout ▸ -/// Rules) — "lowercase UUIDv4, immutable, never renamed" — but the model stores whatever the -/// folder is actually named. It must round-trip byte-perfect (it is the primary key) and is -/// the display-order tie-break (`Ranks.sortedForDisplay`). Deliberately **not** Foundation's +/// Folder names are lowercase UUIDv4, gated at load time (01-storage-format.md § Fractal +/// layout ▸ Rules, "Name shape gates level detection") — `BoardLoader` only ever promotes a +/// UUID-*shaped* folder (lowercase hex, `8-4-4-4-12`; version/variant nibbles unchecked) to a +/// `Lane`/`Card` in the first place, so every `ItemID` reaching this type already has that +/// shape. The model still stores whatever the folder is actually named rather than +/// re-validating or normalizing it: it must round-trip byte-perfect (it is the primary key) and +/// is the display-order tie-break (`Ranks.sortedForDisplay`). Deliberately **not** Foundation's /// `UUID`, which normalizes to uppercase and would silently corrupt that round-trip. /// /// Board roots don't get one of these: a board's folder name is a human/Finder-assigned diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 3e8337d..8d9eb51 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -47,6 +47,15 @@ private struct BoardFixture { } } +/// A fresh folder name with UUIDv4's shape (lowercase hex, `8-4-4-4-12`) — the only shape +/// `BoardLoader` accepts as a lane/card candidate (01-storage-format.md § Fractal layout ▸ +/// Rules, "Name shape gates level detection"). Used wherever a test just needs *a* valid +/// lane/card identity and doesn't care about the exact value; tests that need a specific +/// lexicographic ordering use literal UUID-shaped strings instead. +private func uuidFolderName() -> String { + UUID().uuidString.lowercased() +} + private func expectFailure( _ expectedReason: BoardLoadError.Reason, path: String, @@ -70,14 +79,20 @@ struct BoardLoaderWellFormedTests { let fixture = try BoardFixture() defer { fixture.tearDown() } - try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n") - try fixture.index("b-lane", "schema: 1\norder: 2048\ntitle: B Lane\n") - try fixture.index("a-lane", "schema: 1\norder: 1024\ntitle: A Lane\n") + let laneA = uuidFolderName() + let laneB = uuidFolderName() + let cardFirst = uuidFolderName() + let cardSecond = uuidFolderName() + let cardDeleted = uuidFolderName() - try fixture.index("a-lane/card-2", "schema: 1\norder: 2048\ntitle: Second\n") - try fixture.index("a-lane/card-1", "schema: 1\norder: 1024\ntitle: First\n") + try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n") + try fixture.index(laneB, "schema: 1\norder: 2048\ntitle: B Lane\n") + try fixture.index(laneA, "schema: 1\norder: 1024\ntitle: A Lane\n") + + try fixture.index("\(laneA)/\(cardSecond)", "schema: 1\norder: 2048\ntitle: Second\n") + try fixture.index("\(laneA)/\(cardFirst)", "schema: 1\norder: 1024\ntitle: First\n") try fixture.index( - "a-lane/card-deleted", + "\(laneA)/\(cardDeleted)", "schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n" ) @@ -92,11 +107,11 @@ struct BoardLoaderWellFormedTests { Issue.record("expected template to be a mapping, got \(String(describing: model.template))") } - #expect(model.lanes.map(\.id.rawValue) == ["a-lane", "b-lane"]) + #expect(model.lanes.map(\.id.rawValue) == [laneA, laneB]) - let laneA = try #require(model.lanes.first { $0.id.rawValue == "a-lane" }) - #expect(laneA.cards.map(\.id.rawValue) == ["card-deleted", "card-1", "card-2"]) - #expect(laneA.cards.map(\.isDeleted) == [true, false, false]) + let lane = try #require(model.lanes.first { $0.id.rawValue == laneA }) + #expect(lane.cards.map(\.id.rawValue) == [cardDeleted, cardFirst, cardSecond]) + #expect(lane.cards.map(\.isDeleted) == [true, false, false]) #expect(result.warnings.isEmpty) } @@ -107,86 +122,104 @@ struct BoardLoaderWellFormedTests { let fixture = try BoardFixture() defer { fixture.tearDown() } + let lane = uuidFolderName() + let card = uuidFolderName() + try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\norder: 1024\ndeleted: yesterday\n") - try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\ndeleted: yesterday\n") + try fixture.index(lane, "schema: 1\norder: 1024\ndeleted: yesterday\n") + try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n") let result = try BoardLoader.load(boardRoot: fixture.root) - let lane = try #require(result.model.lanes.first { $0.id.rawValue == "lane-1" }) - #expect(lane.deleted == .malformed(raw: "yesterday")) - #expect(lane.isDeleted) + let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane }) + #expect(loadedLane.deleted == .malformed(raw: "yesterday")) + #expect(loadedLane.isDeleted) - let card = try #require(lane.cards.first { $0.id.rawValue == "card-1" }) - #expect(card.deleted == .malformed(raw: "yesterday")) - #expect(card.isDeleted) + let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card }) + #expect(loadedCard.deleted == .malformed(raw: "yesterday")) + #expect(loadedCard.isDeleted) } @Test func tiesAreBrokenByFolderNameNotTitle() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } + // Both UUID-shaped so both are lane candidates; chosen so their lexicographic order + // ('0' < 'f') is known ahead of time. + let laneFirst = "00000000-0000-4000-8000-000000000000" + let laneSecond = "ffffffff-ffff-4fff-8fff-ffffffffffff" + try fixture.index("", "schema: 1\n") // Same order; titles are reversed relative to folder-name order, to catch a loader // that accidentally wires title into the tie-break instead of the folder name. - try fixture.index("zzz-lane", "schema: 1\norder: 1024\ntitle: Should Be Second\n") - try fixture.index("aaa-lane", "schema: 1\norder: 1024\ntitle: Should Be First\n") + try fixture.index(laneSecond, "schema: 1\norder: 1024\ntitle: Should Be Second\n") + try fixture.index(laneFirst, "schema: 1\norder: 1024\ntitle: Should Be First\n") let model = try BoardLoader.load(boardRoot: fixture.root).model - #expect(model.lanes.map(\.id.rawValue) == ["aaa-lane", "zzz-lane"]) + #expect(model.lanes.map(\.id.rawValue) == [laneFirst, laneSecond]) } } -// MARK: - Skip rules +// MARK: - Skip rules (UUID-shaped candidates only) struct BoardLoaderSkipTests { - @Test func indexlessFolderBelowRootIsSkippedWithWarning() throws { + @Test func indexlessUUIDFolderBelowRootIsSkippedWithMissingIndexWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } + let lane = uuidFolderName() + let orphanLane = uuidFolderName() + try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\norder: 1024\n") - try fixture.emptyFolder("orphan-lane") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.emptyFolder(orphanLane) let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"]) - #expect(result.warnings.contains(.missingIndex(path: "orphan-lane"))) + #expect(result.model.lanes.map(\.id.rawValue) == [lane]) + #expect(result.warnings.contains(.missingIndex(path: orphanLane))) } - @Test func indexlessCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws { + @Test func indexlessUUIDCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } + let lane = uuidFolderName() + let card = uuidFolderName() + let orphanCard = uuidFolderName() + try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\norder: 1024\n") - try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n") - try fixture.emptyFolder("lane-1/orphan-card") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") + try fixture.emptyFolder("\(lane)/\(orphanCard)") let result = try BoardLoader.load(boardRoot: fixture.root) - let lane = try #require(result.model.lanes.first) - #expect(lane.cards.map(\.id.rawValue) == ["card-1"]) - #expect(result.warnings.contains(.missingIndex(path: "lane-1/orphan-card"))) + let loadedLane = try #require(result.model.lanes.first) + #expect(loadedLane.cards.map(\.id.rawValue) == [card]) + #expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(orphanCard)"))) } } -// MARK: - Strays ignored +// MARK: - Strays ignored (non-directories, hidden entries, symlinks) struct BoardLoaderStrayTests { @Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } + let lane = uuidFolderName() + let card = uuidFolderName() + try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\norder: 1024\n") - try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") try fixture.strayFile("notes.txt") try fixture.strayFile(".DS_Store") - try fixture.strayFile("lane-1/notes.txt") + try fixture.strayFile("\(lane)/notes.txt") _ = try fixture.emptyFolder(".git") let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"]) - #expect(result.model.lanes[0].cards.map(\.id.rawValue) == ["card-1"]) + #expect(result.model.lanes.map(\.id.rawValue) == [lane]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) #expect(result.warnings.isEmpty) } @@ -194,15 +227,118 @@ struct BoardLoaderStrayTests { let fixture = try BoardFixture() defer { fixture.tearDown() } + let realLane = uuidFolderName() + try fixture.index("", "schema: 1\n") - let realLane = try fixture.index("real-lane", "schema: 1\norder: 1024\n") + let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n") try FileManager.default.createSymbolicLink( at: fixture.root.appendingPathComponent("linked-lane"), - withDestinationURL: realLane + withDestinationURL: realLaneURL ) let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.model.lanes.map(\.id.rawValue) == ["real-lane"]) + #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) + #expect(result.warnings.isEmpty) + } +} + +// MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout ▸ Rules, +// "Name shape gates level detection") + +struct BoardLoaderNonUUIDStrayTests { + /// A non-UUID-shaped folder is a stray even when its `index.md` is perfectly valid + /// lane-shaped content — the name shape gates candidacy before the file is ever read. + @Test func nonUUIDFolderWithValidLaneShapedIndexIsIgnoredWithWarningAndBoardLoads() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let realLane = uuidFolderName() + + try fixture.index("", "schema: 1\n") + try fixture.index(realLane, "schema: 1\norder: 1024\n") + try fixture.index("todo", "schema: 1\norder: 2048\ntitle: Hand-authored lane\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo"))) + } + + /// The motivating case: before this rule, a non-UUID folder with a broken `index.md` + /// (missing required `order`) would fail-fast the whole load. Now the name shape gates it + /// out as a stray before the loader ever parses the file, so the rest of the board still + /// loads. + @Test func nonUUIDFolderWithBrokenIndexIsIgnoredWithWarningAndBoardStillLoads() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let realLane = uuidFolderName() + + try fixture.index("", "schema: 1\n") + try fixture.index(realLane, "schema: 1\norder: 1024\n") + // Missing 'order' — would be a fail-fast .missingOrder if this were UUID-shaped. + try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo"))) + } + + /// Same motivating case, one level down: a non-UUID card-depth folder with a broken + /// `index.md` is a stray, not a fail-fast, and doesn't stop its lane's other cards loading. + @Test func nonUUIDCardFolderWithBrokenIndexIsIgnoredWithWarningAndLaneStillLoads() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = uuidFolderName() + let realCard = uuidFolderName() + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(realCard)", "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/scratch", "schema: 1\n") // missing 'order' too + + let result = try BoardLoader.load(boardRoot: fixture.root) + let loadedLane = try #require(result.model.lanes.first) + #expect(loadedLane.cards.map(\.id.rawValue) == [realCard]) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/scratch"))) + } + + /// Case sensitivity: an uppercase (or mixed-case) UUID string doesn't have UUIDv4's + /// *lowercase* shape, so it's a stray — folder names are never normalized. + @Test func uppercaseUUIDFolderIsTreatedAsNonUUIDStray() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let realLane = uuidFolderName() + let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. + + try fixture.index("", "schema: 1\n") + try fixture.index(realLane, "schema: 1\norder: 1024\n") + try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: uppercaseLane))) + } + + /// Reserved card children are covered "by construction" now: `attachments/` and + /// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents + /// anyway (cards are leaves) — either way, they must never surface a warning. + @Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = uuidFolderName() + let card = uuidFolderName() + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n") + try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png") + try fixture.strayFile("\(lane)/\(card)/comments/whatever.md") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.first?.cards.map(\.id.rawValue) == [card]) #expect(result.warnings.isEmpty) } } @@ -320,39 +456,48 @@ struct BoardLoaderFailFastTests { } } - @Test func missingOrderOnLaneThrows() throws { + /// A UUID-shaped folder still fails fast on structurally-bad content — the name shape only + /// gates *candidacy*, never the validity of a folder that qualifies. + @Test func missingOrderOnUUIDLaneThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } - try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\n") + let lane = uuidFolderName() - expectFailure(.missingOrder, path: "lane-1/index.md") { + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\n") + + expectFailure(.missingOrder, path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } - @Test func malformedOrderOnLaneThrows() throws { + @Test func malformedOrderOnUUIDLaneThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } - try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\norder: not-a-number\n") + let lane = uuidFolderName() - expectFailure(.malformedOrder(raw: "not-a-number"), path: "lane-1/index.md") { + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: not-a-number\n") + + expectFailure(.malformedOrder(raw: "not-a-number"), path: "\(lane)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } } - @Test func missingOrderOnCardThrows() throws { + @Test func missingOrderOnUUIDCardThrows() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } - try fixture.index("", "schema: 1\n") - try fixture.index("lane-1", "schema: 1\norder: 1024\n") - try fixture.index("lane-1/card-1", "schema: 1\n") + let lane = uuidFolderName() + let card = uuidFolderName() - expectFailure(.missingOrder, path: "lane-1/card-1/index.md") { + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(card)", "schema: 1\n") + + expectFailure(.missingOrder, path: "\(lane)/\(card)/index.md") { _ = try BoardLoader.load(boardRoot: fixture.root) } }