Gate level detection on UUID folder-name shape
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
This commit is contained in:
@@ -8,13 +8,21 @@ import os
|
|||||||
/// partial result.
|
/// partial result.
|
||||||
///
|
///
|
||||||
/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders →
|
/// 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
|
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸
|
||||||
/// regardless of its name — no UUID-shape filtering, no name-based gating.
|
/// 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*
|
/// 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
|
/// (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
|
/// 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
|
/// 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
|
/// 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] = []
|
var lanes: [Lane] = []
|
||||||
for laneURL in try directoryCandidates(in: boardRoot) {
|
for laneURL in try directoryCandidates(in: boardRoot) {
|
||||||
let laneName = laneURL.lastPathComponent
|
let laneName = laneURL.lastPathComponent
|
||||||
|
guard isUUIDShaped(laneName) else {
|
||||||
|
warn(.nonUUIDFolderIgnored(path: laneName))
|
||||||
|
continue
|
||||||
|
}
|
||||||
guard hasIndex(laneURL) else {
|
guard hasIndex(laneURL) else {
|
||||||
warn(.missingIndex(path: laneName))
|
warn(.missingIndex(path: laneName))
|
||||||
continue
|
continue
|
||||||
@@ -76,6 +88,10 @@ public enum BoardLoader: Sendable {
|
|||||||
for cardURL in try directoryCandidates(in: laneURL) {
|
for cardURL in try directoryCandidates(in: laneURL) {
|
||||||
let cardName = cardURL.lastPathComponent
|
let cardName = cardURL.lastPathComponent
|
||||||
let cardRelPath = laneName + "/" + cardName
|
let cardRelPath = laneName + "/" + cardName
|
||||||
|
guard isUUIDShaped(cardName) else {
|
||||||
|
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
guard hasIndex(cardURL) else {
|
guard hasIndex(cardURL) else {
|
||||||
warn(.missingIndex(path: cardRelPath))
|
warn(.missingIndex(path: cardRelPath))
|
||||||
continue
|
continue
|
||||||
@@ -155,10 +171,31 @@ public enum BoardLoader: Sendable {
|
|||||||
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
|
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
|
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
|
||||||
/// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
|
/// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
|
||||||
/// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded
|
/// 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
|
/// 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
|
/// 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`
|
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
|
||||||
/// for what does.
|
/// for what does.
|
||||||
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
||||||
/// A folder below the board root has no `index.md` — skipped, not fail-fast (an
|
/// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not
|
||||||
/// interrupted two-step create must not brick the board). `path` is relative to the board
|
/// fail-fast (an interrupted two-step create must not brick the board). `path` is relative
|
||||||
/// root.
|
/// 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)
|
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
|
/// A board-level `deleted:` key is legal per the frontmatter table but meaningless
|
||||||
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
|
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
|
||||||
case boardLevelDeletedIgnored
|
case boardLevelDeletedIgnored
|
||||||
@@ -256,6 +302,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
|||||||
switch self {
|
switch self {
|
||||||
case let .missingIndex(path):
|
case let .missingIndex(path):
|
||||||
"\(path): folder has no index.md, skipped"
|
"\(path): folder has no index.md, skipped"
|
||||||
|
case let .nonUUIDFolderIgnored(path):
|
||||||
|
"\(path): folder name is not UUID-shaped, ignored as a stray"
|
||||||
case .boardLevelDeletedIgnored:
|
case .boardLevelDeletedIgnored:
|
||||||
"index.md: board-level 'deleted' key is meaningless, ignored"
|
"index.md: board-level 'deleted' key is meaningless, ignored"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import Foundation
|
|||||||
|
|
||||||
/// 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.
|
||||||
///
|
///
|
||||||
/// Folder names are lowercase UUIDv4 by convention (01-storage-format.md § Fractal layout ▸
|
/// Folder names are lowercase UUIDv4, gated at load time (01-storage-format.md § Fractal
|
||||||
/// Rules) — "lowercase UUIDv4, immutable, never renamed" — but the model stores whatever the
|
/// layout ▸ Rules, "Name shape gates level detection") — `BoardLoader` only ever promotes a
|
||||||
/// folder is actually named. It must round-trip byte-perfect (it is the primary key) and is
|
/// UUID-*shaped* folder (lowercase hex, `8-4-4-4-12`; version/variant nibbles unchecked) to a
|
||||||
/// the display-order tie-break (`Ranks.sortedForDisplay`). Deliberately **not** Foundation's
|
/// `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.
|
/// `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
|
/// Board roots don't get one of these: a board's folder name is a human/Finder-assigned
|
||||||
|
|||||||
@@ -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(
|
private func expectFailure(
|
||||||
_ expectedReason: BoardLoadError.Reason,
|
_ expectedReason: BoardLoadError.Reason,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -70,14 +79,20 @@ struct BoardLoaderWellFormedTests {
|
|||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n")
|
let laneA = uuidFolderName()
|
||||||
try fixture.index("b-lane", "schema: 1\norder: 2048\ntitle: B Lane\n")
|
let laneB = uuidFolderName()
|
||||||
try fixture.index("a-lane", "schema: 1\norder: 1024\ntitle: A Lane\n")
|
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("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n")
|
||||||
try fixture.index("a-lane/card-1", "schema: 1\norder: 1024\ntitle: First\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(
|
try fixture.index(
|
||||||
"a-lane/card-deleted",
|
"\(laneA)/\(cardDeleted)",
|
||||||
"schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n"
|
"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))")
|
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" })
|
let lane = try #require(model.lanes.first { $0.id.rawValue == laneA })
|
||||||
#expect(laneA.cards.map(\.id.rawValue) == ["card-deleted", "card-1", "card-2"])
|
#expect(lane.cards.map(\.id.rawValue) == [cardDeleted, cardFirst, cardSecond])
|
||||||
#expect(laneA.cards.map(\.isDeleted) == [true, false, false])
|
#expect(lane.cards.map(\.isDeleted) == [true, false, false])
|
||||||
#expect(result.warnings.isEmpty)
|
#expect(result.warnings.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,86 +122,104 @@ struct BoardLoaderWellFormedTests {
|
|||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidFolderName()
|
||||||
|
let card = uuidFolderName()
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
try fixture.index("", "schema: 1\n")
|
||||||
try fixture.index("lane-1", "schema: 1\norder: 1024\ndeleted: yesterday\n")
|
try fixture.index(lane, "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)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n")
|
||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
let lane = try #require(result.model.lanes.first { $0.id.rawValue == "lane-1" })
|
let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane })
|
||||||
#expect(lane.deleted == .malformed(raw: "yesterday"))
|
#expect(loadedLane.deleted == .malformed(raw: "yesterday"))
|
||||||
#expect(lane.isDeleted)
|
#expect(loadedLane.isDeleted)
|
||||||
|
|
||||||
let card = try #require(lane.cards.first { $0.id.rawValue == "card-1" })
|
let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card })
|
||||||
#expect(card.deleted == .malformed(raw: "yesterday"))
|
#expect(loadedCard.deleted == .malformed(raw: "yesterday"))
|
||||||
#expect(card.isDeleted)
|
#expect(loadedCard.isDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func tiesAreBrokenByFolderNameNotTitle() throws {
|
@Test func tiesAreBrokenByFolderNameNotTitle() throws {
|
||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
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")
|
try fixture.index("", "schema: 1\n")
|
||||||
// Same order; titles are reversed relative to folder-name order, to catch a loader
|
// 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.
|
// 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(laneSecond, "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(laneFirst, "schema: 1\norder: 1024\ntitle: Should Be First\n")
|
||||||
|
|
||||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
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 {
|
struct BoardLoaderSkipTests {
|
||||||
@Test func indexlessFolderBelowRootIsSkippedWithWarning() throws {
|
@Test func indexlessUUIDFolderBelowRootIsSkippedWithMissingIndexWarning() throws {
|
||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidFolderName()
|
||||||
|
let orphanLane = uuidFolderName()
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
try fixture.index("", "schema: 1\n")
|
||||||
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
try fixture.emptyFolder("orphan-lane")
|
try fixture.emptyFolder(orphanLane)
|
||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
#expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"])
|
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
|
||||||
#expect(result.warnings.contains(.missingIndex(path: "orphan-lane")))
|
#expect(result.warnings.contains(.missingIndex(path: orphanLane)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func indexlessCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws {
|
@Test func indexlessUUIDCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws {
|
||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidFolderName()
|
||||||
|
let card = uuidFolderName()
|
||||||
|
let orphanCard = uuidFolderName()
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
try fixture.index("", "schema: 1\n")
|
||||||
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n")
|
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
|
||||||
try fixture.emptyFolder("lane-1/orphan-card")
|
try fixture.emptyFolder("\(lane)/\(orphanCard)")
|
||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
let lane = try #require(result.model.lanes.first)
|
let loadedLane = try #require(result.model.lanes.first)
|
||||||
#expect(lane.cards.map(\.id.rawValue) == ["card-1"])
|
#expect(loadedLane.cards.map(\.id.rawValue) == [card])
|
||||||
#expect(result.warnings.contains(.missingIndex(path: "lane-1/orphan-card")))
|
#expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(orphanCard)")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Strays ignored
|
// MARK: - Strays ignored (non-directories, hidden entries, symlinks)
|
||||||
|
|
||||||
struct BoardLoaderStrayTests {
|
struct BoardLoaderStrayTests {
|
||||||
@Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws {
|
@Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws {
|
||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidFolderName()
|
||||||
|
let card = uuidFolderName()
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
try fixture.index("", "schema: 1\n")
|
||||||
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n")
|
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
try fixture.strayFile("notes.txt")
|
try fixture.strayFile("notes.txt")
|
||||||
try fixture.strayFile(".DS_Store")
|
try fixture.strayFile(".DS_Store")
|
||||||
try fixture.strayFile("lane-1/notes.txt")
|
try fixture.strayFile("\(lane)/notes.txt")
|
||||||
_ = try fixture.emptyFolder(".git")
|
_ = try fixture.emptyFolder(".git")
|
||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
#expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"])
|
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
|
||||||
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == ["card-1"])
|
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
|
||||||
#expect(result.warnings.isEmpty)
|
#expect(result.warnings.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,15 +227,118 @@ struct BoardLoaderStrayTests {
|
|||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let realLane = uuidFolderName()
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
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(
|
try FileManager.default.createSymbolicLink(
|
||||||
at: fixture.root.appendingPathComponent("linked-lane"),
|
at: fixture.root.appendingPathComponent("linked-lane"),
|
||||||
withDestinationURL: realLane
|
withDestinationURL: realLaneURL
|
||||||
)
|
)
|
||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
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)
|
#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()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
let lane = uuidFolderName()
|
||||||
try fixture.index("lane-1", "schema: 1\n")
|
|
||||||
|
|
||||||
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)
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func malformedOrderOnLaneThrows() throws {
|
@Test func malformedOrderOnUUIDLaneThrows() throws {
|
||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
let lane = uuidFolderName()
|
||||||
try fixture.index("lane-1", "schema: 1\norder: not-a-number\n")
|
|
||||||
|
|
||||||
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)
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingOrderOnCardThrows() throws {
|
@Test func missingOrderOnUUIDCardThrows() throws {
|
||||||
let fixture = try BoardFixture()
|
let fixture = try BoardFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
try fixture.index("", "schema: 1\n")
|
let lane = uuidFolderName()
|
||||||
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
let card = uuidFolderName()
|
||||||
try fixture.index("lane-1/card-1", "schema: 1\n")
|
|
||||||
|
|
||||||
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)
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user