From 4418b7f9815498031964c17bef6959613ca9d8d1 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 20:53:38 -0400 Subject: [PATCH] Align the identity predicate with the ratified shape-only rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12 hex in any case and any UUID version — uuidgen and UUID().uuidString print uppercase, and a strict lowercase gate would silently stray an agent's standard-tool card. Identity comparison is UUID-value equality everywhere: ItemID keeps its byte-faithful rawValue but equates and hashes on the lowercased canonical form, and the writer's import-boundary collision check canonicalizes, so a same-UUID arrival spelled in another case remints instead of slipping past. The app still mints only lowercase v4 and never renames to canonicalize. Full suite 342 tests in 63 suites green. Two findings filed. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Fixtures/README.md | 2 +- Kanban/Storage/BoardLoader.swift | 48 ++++++---- Kanban/Storage/BoardModel.swift | 47 ++++++++-- Kanban/Storage/BoardWriter.swift | 56 +++++++++--- KanbanTests/BoardLoaderTests.swift | 139 ++++++++++++++++++++++++++--- KanbanTests/BoardWriterTests.swift | 60 +++++++++++++ 6 files changed, 301 insertions(+), 51 deletions(-) diff --git a/Fixtures/README.md b/Fixtures/README.md index 5e6f019..2a7fdc9 100644 --- a/Fixtures/README.md +++ b/Fixtures/README.md @@ -4,7 +4,7 @@ Golden fixture boards for the storage-contract test suite — real on-disk folde Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`. -Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. +Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. That is a fixture-authoring convention, not the loader's gate: the identity predicate is shape-only (`8-4-4-4-12` hex, **any case, any version** — 01-storage-format.md § Fractal layout ▸ Rules), and the case/version coverage lives in `KanbanTests/BoardLoaderTests.swift` rather than here. ## Valid/ — one board per tolerated/valid case diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index fb8741a..8014402 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -9,8 +9,9 @@ import os /// /// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders → /// 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. +/// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any +/// version** — 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 @@ -175,26 +176,40 @@ 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") + /// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**, + /// per the shape-only identity predicate below. + private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF") - /// 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`). + /// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** — + /// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name + /// shape gates level detection"). This is *the* identity predicate, and it is deliberately + /// **shape-only**: lowercase v4 is the app's emission rule, not the gate. + /// + /// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*, + /// so a strict lowercase gate would turn an agent's standard-tool card into a silently + /// skipped stray — the worst failure mode for a files-first app. Accept liberally, emit + /// conservatively: `BoardWriter` still writes only lowercase v4 and never renames an + /// existing folder to canonicalize it. + /// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are + /// **not** validated: they protect no invariant here — an agent's v7 is exactly as unique + /// as a v4 — and the loader's job is recognizing the folder-naming *convention*, not + /// re-deriving RFC 4122 conformance every load. + /// + /// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md + /// states the rule; kept as a manual scan because that is the cheaper answer on the hot path + /// (every folder of every load) and needs no bridging. + /// + /// Recognizing a name is not the same as *comparing* two of them: identity comparison is + /// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere — + /// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares + /// canonically. /// /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same /// candidates the loader walked, and level detection has to be one rule, not two. 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) } + return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) } } /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding @@ -310,7 +325,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { /// this case. case missingIndex(path: String) - /// A lane/card-depth folder whose name doesn't have UUIDv4's shape (`isUUIDShaped`) — + /// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex, + /// `8-4-4-4-12`, any case, any version) — /// 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 diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index 8c9016b..a055e3f 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -7,16 +7,31 @@ import Foundation /// lane, depth 2 = card); there is deliberately no `type`/`level` discriminator field — /// the three distinct Swift types encode it structurally. -/// The immutable identity of a lane or card folder: its exact name, byte-for-byte. +/// The immutable identity of a lane or card folder: its exact name, byte-for-byte — compared as +/// a UUID *value*. /// -/// 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. +/// **`rawValue` is the folder's exact spelling.** It builds URLs, it must round-trip +/// byte-perfect (it is the primary key on disk), and it is the display-order tie-break +/// (`Ranks.sortedForDisplay`), so it is never normalized on the way in. Deliberately **not** +/// Foundation's `UUID`, which re-renders as uppercase and would silently corrupt that +/// round-trip. +/// +/// **Equality and hashing are by UUID value, not by spelling** (01-storage-format.md § Fractal +/// layout ▸ Rules, settled): *two case-spellings of one UUID are one identity everywhere* — +/// selection membership, the import-boundary collision check, every `Set`/`Dictionary` keyed by +/// this type. That matches default-APFS case-insensitivity, where the two spellings name one +/// folder anyway. Lowercasing `rawValue` *is* the canonical form: `BoardLoader` only ever mints +/// an `ItemID` for a folder name that passed the shape gate (`isUUIDShaped` — hex, `8-4-4-4-12`, +/// any case, any version), and `BoardWriter` only ever mints one for a name it just wrote, so +/// the string is always ASCII hex and hyphens, where case folding is exactly UUID-value +/// canonicalization. Nothing asserts that — the type stays total on any string a caller hands it; +/// off-shape input simply compares by its own lowercasing, which is the harmless reading. +/// +/// Consequences, all intended: `RawRepresentable` is unaffected — only `==` and `hash(into:)` +/// are hand-written, and `rawValue` still reads back exactly as it was stored; +/// SwiftUI `Identifiable` diffing keys on this same value equality, so a case-respelled folder +/// is the *same* row rather than a delete plus an insert; and a `Set` holding both +/// spellings collapses them to one member, keeping whichever arrived first. /// /// Board roots don't get one of these: a board's folder name is a human/Finder-assigned /// `.kanban` package name, not a UUID (01-storage-format.md § Board naming) — its identity is @@ -28,6 +43,20 @@ public struct ItemID: Hashable, Sendable, RawRepresentable { public init(rawValue: String) { self.rawValue = rawValue } + + /// The comparison key: `rawValue` case-folded. Computed rather than stored so `ItemID` stays + /// one string wide and `rawValue` remains the single source of truth for what is on disk. + /// `lowercased()` is locale-independent, and every identity-shaped name is ASCII, so this is + /// UUID-value canonicalization and nothing more. + var canonicalValue: String { rawValue.lowercased() } + + public static func == (lhs: ItemID, rhs: ItemID) -> Bool { + lhs.canonicalValue == rhs.canonicalValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(canonicalValue) + } } extension ItemID: CustomStringConvertible { diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 959a859..c37995b 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -259,15 +259,19 @@ public enum BoardWriter: Sendable { /// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase - /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match - /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. + /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is the app's **emission** + /// rule — accept liberally, emit conservatively. The loader's gate + /// (`BoardLoader.isUUIDShaped`) accepts either case, so this lowercasing is a convention the + /// app holds itself to, not something a reader depends on. /// /// Two exclusions, both re-minted rather than assumed away: a name already on disk in /// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a /// race with an existing entry), and any name in `taken` — the identities a collision repair - /// is minting *away* from, which are not necessarily on disk here. A freshly minted UUID - /// hitting either is astronomically unlikely — 122 bits of randomness per mint — but the - /// loop body is trivial precisely because the case it handles essentially never fires. + /// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical** + /// (lowercased, `canonicalIdentity`), which is what makes the `contains` a UUID-*value* + /// probe: the minted name is lowercase, so it can only match a canonical set. A freshly + /// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint — + /// but the loop body is trivial precisely because the case it handles essentially never fires. private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set) -> String { var name: String repeat { @@ -439,7 +443,10 @@ public enum BoardWriter: Sendable { /// siblings — while the moved item is still elsewhere and so cannot count itself. /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` + /// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a - /// same-board move cannot collide with anything but itself. + /// same-board move cannot collide with anything but itself. The scan and every probe + /// against it are **by UUID value, not spelling** (`identities(inBoard:)` / + /// `canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…` + /// spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules). /// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across /// volumes). A colliding *root* is renamed by moving it straight to its minted name /// rather than moving and then renaming: one filesystem operation instead of two, and it @@ -492,7 +499,15 @@ public enum BoardWriter: Sendable { rank = order } else { let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false) - rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.map(\.order)) + // "Which sibling is the item itself" is an identity question, so it is asked by + // UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the + // directory listing can disagree in case for one and the same folder. + let selfIdentity = canonicalIdentity(sourceName) + rank = Ranks.append( + toVisible: siblings + .filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity } + .map(\.order) + ) } try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) @@ -508,7 +523,7 @@ public enum BoardWriter: Sendable { var reminted: [MoveResult.Remint] = [] var arrivedName = sourceName - if existing.contains(sourceName) { + if existing.contains(canonicalIdentity(sourceName)) { arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved) reserved.insert(arrivedName) reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName))) @@ -527,8 +542,8 @@ public enum BoardWriter: Sendable { if isImport { let children = childCandidates(of: arrivedRoot) - reserved.formUnion(children.map(\.lastPathComponent)) - for child in children where existing.contains(child.lastPathComponent) { + reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) }) + for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) { let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved) reserved.insert(fresh) try renameFolder(child, toSiblingNamed: fresh, operation: operation) @@ -561,17 +576,30 @@ public enum BoardWriter: Sendable { /// and the conservative direction here — a missed identity remints nothing, and a duplicate /// UUID in one board is the unspecified-behavior case the design already names, not a /// corruption. + /// **Canonical, not verbatim**: every name is lowercased on the way in (`canonicalIdentity`), + /// and every probe against the returned set must be too. Identity comparison is UUID-*value* + /// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving + /// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a + /// verbatim set would miss exactly that collision and let a duplicate UUID into the board. private static func identities(inBoard boardRoot: URL) -> Set { var identities: Set = [] for lane in childCandidates(of: boardRoot) { - identities.insert(lane.lastPathComponent) + identities.insert(canonicalIdentity(lane.lastPathComponent)) for card in childCandidates(of: lane) { - identities.insert(card.lastPathComponent) + identities.insert(canonicalIdentity(card.lastPathComponent)) } } return identities } + /// A folder name reduced to its identity *value* — the same canonicalization `ItemID`'s + /// `==`/`hash(into:)` use (`BoardModel.swift`), applied where this writer must compare names + /// as strings because it is working with paths rather than model values. Every identity-shaped + /// name is ASCII hex and hyphens, so case folding is UUID-value canonicalization exactly. + private static func canonicalIdentity(_ folderName: String) -> String { + folderName.lowercased() + } + /// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden /// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's /// level-detection rule and therefore the only definition of "an identity-bearing child" @@ -1017,8 +1045,8 @@ public enum BoardWriter: Sendable { } /// Refuses a folder that is not a lane or a card. Level detection is by name shape - /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase - /// UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or + /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, a truncated or + /// non-hex UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or /// purging one as if it were would invent (or destroy) an identity the loader would /// otherwise just ignore. Shared by every operation that must never reach a board root: a /// board root's folder name is never UUID-shaped (§ Board naming), so this one check is diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index b8f75d5..773d81d 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -47,11 +47,12 @@ 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. +/// A fresh folder name in the app's own emission spelling — lowercase v4. The loader's gate is +/// shape-only (hex, `8-4-4-4-12`, any case, any version — 01-storage-format.md § Fractal layout +/// ▸ Rules, "Name shape gates level detection"), so this is *a* valid identity rather than the +/// only kind; the case tests below cover the rest. Used wherever a test just needs some 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() } @@ -303,22 +304,90 @@ struct BoardLoaderNonUUIDStrayTests { #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 { + /// The identity predicate is **shape-only, any case** (01-storage-format.md § Fractal layout + /// ▸ Rules, settled): `uuidgen` and `UUID().uuidString` both print uppercase, so an + /// uppercase folder is an ordinary lane — never a silently skipped stray. Its `rawValue` + /// keeps the exact spelling: the app accepts liberally and never renames to canonicalize. + @Test func uppercaseUUIDFolderIsALaneWithItsSpellingPreserved() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lowercaseLane = uuidFolderName() + let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. + + try fixture.index("", "schema: 1\n") + try fixture.index(lowercaseLane, "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) == [lowercaseLane, uppercaseLane]) + #expect(result.warnings.isEmpty) + } + + /// One level down, and mixed case rather than uniform: an agent's `uuidgen`-named card + /// folder loads as a card, spelling intact. + @Test func mixedCaseUUIDCardFolderLoadsAsACardWithItsSpellingPreserved() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let lane = uuidFolderName() + let mixedCaseCard = "AbCdEf01-2345-6789-aBcD-EF0123456789" + + try fixture.index("", "schema: 1\n") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(mixedCaseCard)", "schema: 1\norder: 1024\ntitle: From an agent\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + let loadedLane = try #require(result.model.lanes.first) + #expect(loadedLane.cards.map(\.id.rawValue) == [mixedCaseCard]) + #expect(loadedLane.cards.first?.title.value == "From an agent") + #expect(result.warnings.isEmpty) + } + + /// **Any version**, not just v4: the version nibble protects no invariant here — a v7 (or a + /// nibble no RFC ever assigned) is exactly as unique as a v4, so it is an identity, not a + /// stray. Only the shape is checked. + @Test func oddVersionAndVariantNibblesAreStillIdentities() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + let v7Lane = "01912d5e-7c00-7000-8000-abcdefabcdef" // version nibble 7 + let oddLane = "01912d5e-7c00-c000-f000-abcdefabcdef" // version c, variant f — no RFC's + + try fixture.index("", "schema: 1\n") + try fixture.index(v7Lane, "schema: 1\norder: 1024\n") + try fixture.index(oddLane, "schema: 1\norder: 2048\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.lanes.map(\.id.rawValue) == [v7Lane, oddLane]) + #expect(result.warnings.isEmpty) + } + + /// The shape itself is still strict: near-misses stay strays. Hex only, exact group lengths, + /// hyphens exactly where they belong. + @Test func nearMissUUIDShapesAreStillStrays() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } let realLane = uuidFolderName() - let uppercaseLane = UUID().uuidString // Foundation renders this uppercase. + let strays = [ + "GGGGGGGG-0000-4000-8000-000000000000", // not hex + "00000000-0000-4000-8000-00000000000", // one digit short + "000000000-000-4000-8000-000000000000", // hyphens misplaced + "00000000-0000-4000-8000-000000000000-", // trailing hyphen + ] try fixture.index("", "schema: 1\n") try fixture.index(realLane, "schema: 1\norder: 1024\n") - try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n") + for (offset, stray) in strays.enumerated() { + try fixture.index(stray, "schema: 1\norder: \(2048 + offset)\n") + } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) - #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: uppercaseLane))) + for stray in strays { + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: stray))) + } } /// Reserved card children are covered "by construction" now: `attachments/` and @@ -343,6 +412,54 @@ struct BoardLoaderNonUUIDStrayTests { } } +// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout ▸ Rules, "Identity +// comparison is UUID-value equality, never string equality") + +/// `ItemID` stores the folder's exact spelling — it builds URLs — but *compares* as a UUID +/// value: two case-spellings of one UUID are one identity everywhere. +struct ItemIDValueSemanticsTests { + private static let lower = "abcdef01-2345-6789-abcd-ef0123456789" + private static let upper = "ABCDEF01-2345-6789-ABCD-EF0123456789" + private static let mixed = "AbCdEf01-2345-6789-aBcD-eF0123456789" + + @Test func caseSpellingsOfOneUUIDAreEqualAndHashAlike() { + let lower = ItemID(rawValue: Self.lower) + let upper = ItemID(rawValue: Self.upper) + let mixed = ItemID(rawValue: Self.mixed) + + #expect(lower == upper) + #expect(lower == mixed) + #expect(upper == mixed) + #expect(lower.hashValue == upper.hashValue) + #expect(upper.hashValue == mixed.hashValue) + } + + /// Equality is by value, but the spelling is never rewritten — the app accepts liberally and + /// emits conservatively, and `rawValue` is what builds the folder's URL. + @Test func rawValueKeepsTheExactSpelling() { + #expect(ItemID(rawValue: Self.upper).rawValue == Self.upper) + #expect(ItemID(rawValue: Self.mixed).description == Self.mixed) + } + + @Test func distinctUUIDsAreUnequalHoweverTheyAreSpelled() { + let one = ItemID(rawValue: "abcdef01-2345-6789-abcd-ef0123456789") + let other = ItemID(rawValue: "ABCDEF01-2345-6789-ABCD-EF012345678A") + #expect(one != other) + } + + /// The consequence every `Set`/`Dictionary` keyed by `ItemID` inherits — selection + /// membership included (`BoardStore.Selection`). + @Test func aSetCollapsesTheTwoSpellingsToOneMember() { + let set: Set = [ItemID(rawValue: Self.lower), ItemID(rawValue: Self.upper)] + #expect(set.count == 1) + #expect(set.contains(ItemID(rawValue: Self.mixed))) + + var byID: [ItemID: String] = [:] + byID[ItemID(rawValue: Self.upper)] = "written uppercase" + #expect(byID[ItemID(rawValue: Self.lower)] == "written uppercase") + } +} + // MARK: - Board-level deleted struct BoardLoaderBoardLevelDeletedTests { diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 108e13a..bedc122 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -1014,6 +1014,66 @@ struct BoardWriterMoveTests { #expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") == twinBefore) } + /// **Identity comparison is UUID-value equality, never string equality** (01-storage-format.md + /// § Fractal layout ▸ Rules, settled): the destination board already holds the arriving UUID + /// spelled in *uppercase* — an agent's `uuidgen` card — so the two are one identity and the + /// import boundary must remint. A verbatim string set would sail straight past this and leave + /// a duplicate UUID in the board. + @Test func aCollisionSpelledInADifferentCaseIsStillOneIdentityAndRemints() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + + let arriving = "abcdef01-2345-6789-abcd-ef0123456789" + let twin = arriving.uppercased() // The same UUID, as `uuidgen` would have printed it. + try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Card One")) + try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "Stale Twin")) + let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)") + + let result = try move( + fixture, "A.kanban/\(Ident.lane1)/\(arriving)", + to: "B.kanban/\(Ident.lane4)", into: "B.kanban" + ) + + let minted = result.id.rawValue + #expect(BoardLoader.isUUIDShaped(minted)) + #expect(ItemID(rawValue: minted) != ItemID(rawValue: arriving)) + #expect(ItemID(rawValue: minted) != ItemID(rawValue: twin)) + #expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: arriving), to: ItemID(rawValue: minted))]) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)")).title + == .valid("Card One")) + + // The resident twin is untouched, and the source left as for any move. + #expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)") == twinBefore) + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(arriving)")) + } + + /// The same rule one level down: a lane arrives carrying a card whose UUID the destination + /// board already holds under a different case-spelling — that card, and only that card, is + /// reminted. + @Test func aLaneMoveRemintsAChildCollidingOnlyByCaseSpelling() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + + let arriving = "abcdef01-2345-6789-abcd-ef0123456789" + let twin = arriving.uppercased() + try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Colliding")) + try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "B's Own")) + + let result = try move(fixture, "A.kanban/\(Ident.lane1)", to: "B.kanban", into: "B.kanban") + + #expect(result.id.rawValue == Ident.lane1) + #expect(result.reminted.map(\.from.rawValue) == [arriving]) + let minted = try #require(result.reminted.first?.to.rawValue) + #expect(fixture.exists("B.kanban/\(Ident.lane1)/\(minted)")) + #expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(arriving)")) + // The card that collided with nothing kept its identity. + #expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card1)")) + } + /// The collision sitting in the very lane being dropped into — the case a move-then-rename /// could not repair, because the plain move would fail on the existing name. @Test func aCollisionInTheDestinationParentItselfIsStillReminted() throws {