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.
|
||||
///
|
||||
/// 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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user