A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1846 lines
106 KiB
Swift
1846 lines
106 KiB
Swift
import Foundation
|
|
import Synchronization
|
|
import os
|
|
|
|
/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure
|
|
/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal
|
|
/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input)
|
|
/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent
|
|
/// partial result.
|
|
///
|
|
/// 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 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
|
|
/// 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), and cards are leaves *structurally*: the
|
|
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
|
|
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
|
|
/// levels, so they never need special-casing against the stray warning.
|
|
///
|
|
/// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither
|
|
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
|
|
///
|
|
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
|
|
/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
|
|
/// face), and the snapshot is where it reads from.
|
|
/// - `looseFileNames(in:ignoring:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`.
|
|
/// This is the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸
|
|
/// Rules, settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
|
|
/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
|
|
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
|
|
/// Writer-mediated app write the store schedules off the snapshot
|
|
/// (`BoardStore.relocateLooseCardFiles`).
|
|
///
|
|
/// Everything else about a card folder's contents remains outside this loader's business.
|
|
///
|
|
/// **A third read, once per walk rather than per card**: the board root's `.gitignore`, which is the
|
|
/// noise gate the detection above obeys (§ Rules, ruled 2026-07-31 — `ignoreRules(atBoardRoot:)`). A
|
|
/// file it matches is not a defect at all; it keeps the ordinary stray posture.
|
|
///
|
|
/// 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
|
|
/// cross-volume or cyclic trees.
|
|
///
|
|
/// ## The trash is a container, not a level
|
|
///
|
|
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
|
|
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
|
|
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
|
|
/// the lanes, parsing its UUID-shaped children through the same `schema`/`order` rulebook and the
|
|
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
|
|
/// warns; absent, the trash is simply empty.
|
|
///
|
|
/// **`kind:` decides what each entry is** (`IntegrityRules.trashKind`): depth defines meaning on the
|
|
/// live board, but the trash is flat and an empty lane folder is shape-identical to a card folder.
|
|
/// A card lands in `BoardModel.trash` parsed like any card; a lane lands in
|
|
/// `BoardModel.trashedLanes` as an **opaque unit** — title, rank, and a count of the cards it holds
|
|
/// — and its subtree is never walked into the snapshot (03-board-ui.md § Trash).
|
|
///
|
|
/// ## The migration window
|
|
///
|
|
/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card**
|
|
/// is *migration input* — the card relocates into `.trash/` with the key removed. A key on a lane or
|
|
/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes
|
|
/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is
|
|
/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found
|
|
/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix.
|
|
///
|
|
/// **Until that write lands, such cards still load through the retiring tombstone path** — a
|
|
/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate
|
|
/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe
|
|
/// direction while the fix is still pending (it is deferred under any read-only lock, and can be
|
|
/// pending for a whole session) is for nothing to silently vanish from view before its folder has
|
|
/// actually moved. The window closes per board on the first successful migration write.
|
|
public enum BoardLoader: Sendable {
|
|
|
|
/// Schema version this app understands; anything higher fails fast
|
|
/// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
|
|
/// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
|
|
/// stamps into files it creates — one symbol, so the app can never write a file its own
|
|
/// loader would reject as newer-than-supported.
|
|
static let supportedSchema = 1
|
|
|
|
/// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
|
|
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
|
|
/// than through a typed `FrontmatterDocument` accessor.
|
|
///
|
|
/// Internal rather than `private` for `indexFileName`'s reason: `TemplateEngine` writes this
|
|
/// key on a Save as Template copy — "the one writer of keyed files is Save as Template"
|
|
/// (09-templates.md ▸ Storage) — and the reader and that one writer must never disagree about
|
|
/// how it is spelled.
|
|
static let templateKey = "template"
|
|
|
|
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
|
|
/// the writer must never disagree about which file a folder's content lives in.
|
|
///
|
|
/// The name itself is `IntegrityRules`', with every other reserved name — one table
|
|
/// (02-architecture.md ▸ Components).
|
|
static let indexFileName = IntegrityRules.indexFileName
|
|
|
|
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
|
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
|
/// seeded `.gitignore` on the claimed list.
|
|
///
|
|
/// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
|
|
/// skips hidden entries, so the container can never be mistaken for a lane candidate and can
|
|
/// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
|
|
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
|
/// name, and a board can have only one trash. The name is `IntegrityRules`', with the rest of
|
|
/// the claimed-name table.
|
|
static let trashFolderName = IntegrityRules.trashFolderName
|
|
|
|
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
|
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
|
/// app-claimed, not strays", plus `.trash/` from § Deletion).
|
|
///
|
|
/// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
|
|
/// files are listed because the claim is about names, and a future check that needs the set
|
|
/// should find it complete rather than build a second one. Compared lowercased, like
|
|
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
|
///
|
|
/// The table is `IntegrityRules.claimedRootNames`, which also carries what kind of node each
|
|
/// name is allowed to be — the fact the squatter-displacement heal turns on (ruled 2026-07-29).
|
|
static let reservedRootNames: Set<String> = IntegrityRules.claimedRootNameSet
|
|
|
|
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
|
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
|
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
|
|
/// listed because the schema reserves the name, not because anything writes it yet —
|
|
/// `attachments/` is still the one folder this app ever creates under a card.
|
|
///
|
|
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
|
|
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
|
|
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
|
|
/// names on its own, so a caller passing a hand-made list cannot reach past this rule. The
|
|
/// table itself is `IntegrityRules`', with every other reserved name.
|
|
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
|
|
|
/// The board's noise definition, at board root (01-storage-format.md § Fractal layout ▸ Rules,
|
|
/// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names.
|
|
static let gitignoreFileName = IntegrityRules.gitignoreFileName
|
|
|
|
/// **The defect paths a skip set can never name** (01-storage-format.md § Malformed input, the
|
|
/// decision surface, settled 2026-07-31): the board root's own `index.md`, and the `"."` the
|
|
/// environmental failures carry.
|
|
///
|
|
/// The surface never offers Skip at the root — a root `schema` newer than this app "blocks the
|
|
/// whole board (Cancel is the only exit)", and the other three root defects have minted repairs
|
|
/// (create the index, stamp `schema: 1`) rather than a tolerance. Skipping one anyway would mean
|
|
/// building a `BoardModel` out of a board with no root document and no schema, which is not a
|
|
/// board. Policed in `load(boardRoot:skipping:historyRanker:)` so the impossible snapshot is
|
|
/// impossible *here*, rather than by every future caller remembering not to ask for it.
|
|
static let unskippablePaths: Set<String> = [indexFileName, "."]
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
|
|
|
// MARK: - The noise gate
|
|
|
|
/// **The board-root `.gitignore`, parsed** — the one file the loose-file carve-out consults
|
|
/// before calling anything relocatable (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
|
/// 2026-07-31: "`.gitignore` is the noise gate").
|
|
///
|
|
/// Read **once per walk**, at the top of `load`, and handed down to every card: a board with 400
|
|
/// cards reads this file once, not 400 times. `nil` when the board has none — and `nil` and an
|
|
/// empty file mean exactly the same thing to every caller (nothing is excluded), which is the
|
|
/// ruling's own escape hatch working by construction rather than by a branch.
|
|
///
|
|
/// **Board root only.** "Nested `.gitignore` files are ordinary strays the heal never consults" —
|
|
/// a `.gitignore` inside a lane or a card is a file like any other, and one inside a card is
|
|
/// itself a hidden entry the carve-out never touches.
|
|
///
|
|
/// Reading it here does not make the loader impure: this is a read like `index.md`'s, on a file
|
|
/// the walk's result depends on. Nothing is written, and an unreadable or non-UTF-8 file answers
|
|
/// `nil` — "cannot tell" reads as "excludes nothing", which is the direction that loses no file
|
|
/// (an unrelocated stray stays exactly where it is; the alternative would silently move things
|
|
/// the user meant to be noise).
|
|
static func ignoreRules(atBoardRoot root: URL) -> GitignoreRules? {
|
|
guard let data = try? Data(contentsOf: root.appendingPathComponent(gitignoreFileName)),
|
|
let text = String(data: data, encoding: .utf8)
|
|
else {
|
|
return nil
|
|
}
|
|
return GitignoreRules(parsing: text)
|
|
}
|
|
|
|
// MARK: - The parse memo
|
|
|
|
/// **One `index.md`'s git-index heuristic record** (02-architecture.md § Live-reload resilience,
|
|
/// blessed 2026-07-31: "The walk memoizes its parse, never its result").
|
|
///
|
|
/// Modification date and byte count, and deliberately nothing else: "The mtime+size trust is the
|
|
/// git-index heuristic; a writer that defeats it — content changed, mtime and size both
|
|
/// preserved — is outside the app's care." No content hashing, because a hash is a read of the
|
|
/// whole file and reading the whole file is the cost the memo exists to avoid.
|
|
///
|
|
/// Stat'd through `FileManager.attributesOfItem`, **never** `URL.resourceValues`, which caches
|
|
/// its answers on the `URL` instance: a cached mtime would let the memo answer from a stamp taken
|
|
/// a reload ago, which is exactly the staleness the heuristic exists to detect.
|
|
public struct FileStamp: Sendable, Equatable {
|
|
public let modified: Date
|
|
public let size: Int
|
|
|
|
/// `nil` where the file cannot be stat'd at all — read as "cannot tell", and therefore as a
|
|
/// memo miss: the walk parses, exactly as it did before the memo existed.
|
|
init?(of url: URL) {
|
|
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let modified = attributes[.modificationDate] as? Date,
|
|
let size = attributes[.size] as? NSNumber
|
|
else {
|
|
return nil
|
|
}
|
|
self.modified = modified
|
|
self.size = size.intValue
|
|
}
|
|
}
|
|
|
|
/// **The previous walk's parsed documents, indexed by root-relative path** — the memo
|
|
/// (02-architecture.md § Live-reload resilience, blessed 2026-07-31: "the loader may reuse the
|
|
/// previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged
|
|
/// — the previous snapshot *is* the memo").
|
|
///
|
|
/// **An input to `load`, never hidden state.** The loader is stateless statics and stays that
|
|
/// way: a caller that holds no memo gets a cold walk, and one that holds the last walk's memo
|
|
/// gets the same answer faster. That is the contract stated exactly — "result-purity with cost
|
|
/// unspecified: same tree in, same snapshot out, and the memo can only change how fast".
|
|
///
|
|
/// ### Scope: the parse, and nothing else
|
|
///
|
|
/// A hit skips one thing — opening and parsing one file. Everything *derived* from the document
|
|
/// (`schema`, `order`, the coercion trace, the identity dedupe, the trash's `kind`) is recomputed
|
|
/// from it on every walk, unchanged, which is what makes memoized and cold walks indistinguishable
|
|
/// in output rather than merely intended to be.
|
|
///
|
|
/// **Directory enumeration is never memoized**: folder discovery, attachment listings, loose-file
|
|
/// detection, the trash's entries and the noise gate are read fresh every walk, "because
|
|
/// attachment changes never touch `index.md`" — a memo that covered them would go blind to
|
|
/// precisely the changes the snapshot is supposed to show.
|
|
///
|
|
/// ### A defect can never be answered from it
|
|
///
|
|
/// An entry is recorded only where the file parsed **and** its `schema` reading succeeded — the
|
|
/// two steps that can produce a `BoardLoadError` at all. So a defective `index.md` is never in the
|
|
/// memo, which settles both halves of the collect-all walk's question: a file that is broken and
|
|
/// stays broken has nothing to hit and is re-read and re-collected every walk, and a file whose
|
|
/// defect was repaired moved its mtime and size and would miss anyway. The skip channel inherits
|
|
/// this by construction — a skipped path *is* a defect path — so a skip is recomputed from a fresh
|
|
/// parse every walk and can never be decided from a memo.
|
|
///
|
|
/// Withheld duplicate occurrences *are* recorded, and correctly so: their files parsed cleanly and
|
|
/// only the board-wide dedupe kept them out of the model, and that dedupe runs over the fresh walk
|
|
/// either way.
|
|
public struct ParseMemo: Sendable {
|
|
fileprivate struct Entry: Sendable {
|
|
let stamp: FileStamp
|
|
let document: FrontmatterDocument
|
|
}
|
|
|
|
fileprivate var entries: [String: Entry] = [:]
|
|
|
|
/// The empty memo — a cold walk. The only one a caller ever constructs; every other comes
|
|
/// out of a `LoadResult`.
|
|
public init() {}
|
|
|
|
/// How many documents this memo can answer for. The walk never asks; the suites do.
|
|
public var count: Int { entries.count }
|
|
|
|
fileprivate func document(at path: String, stamp: FileStamp) -> FrontmatterDocument? {
|
|
guard let entry = entries[path], entry.stamp == stamp else { return nil }
|
|
return entry.document
|
|
}
|
|
|
|
fileprivate mutating func record(_ document: FrontmatterDocument, at path: String, stamp: FileStamp?) {
|
|
guard let stamp else { return }
|
|
entries[path] = Entry(stamp: stamp, document: document)
|
|
}
|
|
}
|
|
|
|
/// **What one walk actually read** — the memo's whole claim, made assertable.
|
|
///
|
|
/// The loader's contract is result-purity with *cost unspecified*, and a cost nothing can observe
|
|
/// is a cost nothing can regress: this is the observation handle, so "a single-file echo re-parses
|
|
/// one file, not the tree" is a test rather than a hope.
|
|
///
|
|
/// Injected rather than a static tally, for `IdentityHistoryRanker`'s reason: `load` is stateless
|
|
/// statics called from several tasks at once, and a shared counter would be one mutable answer to
|
|
/// a per-walk question. `nil` — every production call — costs nothing at all.
|
|
public final class ParseCounter: Sendable {
|
|
|
|
/// One walk's tally: files opened and parsed, and documents answered from the memo.
|
|
public struct Counts: Sendable, Equatable {
|
|
public var parsed = 0
|
|
public var reused = 0
|
|
}
|
|
|
|
private let state = Mutex(Counts())
|
|
|
|
public init() {}
|
|
|
|
public var counts: Counts { state.withLock { $0 } }
|
|
|
|
fileprivate func noteParse() { state.withLock { $0.parsed += 1 } }
|
|
fileprivate func noteReuse() { state.withLock { $0.reused += 1 } }
|
|
}
|
|
|
|
/// One `index.md`, read through the memo — the memo's only point of contact with the walk.
|
|
///
|
|
/// A hit is a document the previous walk parsed out of a file whose path, mtime and size have not
|
|
/// moved since; a miss is the ordinary `readDocument(at:path:)`, byte for byte the same call the
|
|
/// loader has always made. The stamp travels back out so the caller can record the document into
|
|
/// *this* walk's memo once its `schema` reading has succeeded — `ParseMemo` states why that, and
|
|
/// not the read, is the recording point.
|
|
///
|
|
/// A file whose stamp cannot be read (`nil`) is always parsed and never recorded: "cannot tell"
|
|
/// reads as "not memoizable", which is the direction that costs a parse rather than correctness.
|
|
private static func memoizedDocument(
|
|
at url: URL,
|
|
path: String,
|
|
memo: ParseMemo?,
|
|
counter: ParseCounter?
|
|
) throws(BoardLoadError) -> (document: FrontmatterDocument, stamp: FileStamp?) {
|
|
let stamp = FileStamp(of: url)
|
|
if let stamp, let hit = memo?.document(at: path, stamp: stamp) {
|
|
counter?.noteReuse()
|
|
return (hit, stamp)
|
|
}
|
|
counter?.noteParse()
|
|
return (try readDocument(at: url, path: path), stamp)
|
|
}
|
|
|
|
// MARK: - Entry point
|
|
|
|
/// Walks the board and answers a snapshot — or **every fail-fast defect the walk found**, as one
|
|
/// aggregate (01-storage-format.md § Malformed input, settled 2026-07-31: "The loader collects
|
|
/// every fail-fast defect in the walk rather than stopping at the first"). One walk, one
|
|
/// `BoardLoadFailure`, and never a chain of modals over the same board.
|
|
///
|
|
/// ### What collecting means at each level
|
|
///
|
|
/// - **Environmental failures stay immediate.** An unreadable root, and a root that is a file
|
|
/// rather than a folder, throw a single-defect aggregate on the spot: there is nothing to walk
|
|
/// and so nothing to aggregate *with*. The surface would show one row either way.
|
|
/// - **The root's own `index.md` defects are collected and the walk continues** — a missing
|
|
/// index, unparseable YAML, a missing or malformed `schema`, a `schema` newer than this app.
|
|
/// Nothing below the root reads the root's document: lanes are enumerated by folder shape,
|
|
/// `.trash/` is reached by name, and the noise gate is its own file. So a board whose root is
|
|
/// broken *and* whose lanes are broken reports both in one pass, which is what lets the
|
|
/// surface state every class at once instead of revealing the next one per repair.
|
|
/// - **A below-root defect skips that item's subtree.** A lane whose `index.md` will not parse
|
|
/// is recorded and its cards are never enumerated; a card's defect takes that card out. This is
|
|
/// the designed loop rather than a gap: Repair and Open and Re-check both "re-run the whole
|
|
/// walk", so a repaired lane re-aggregates with whatever it was hiding, in the *same* surface.
|
|
///
|
|
/// Defect order is walk order — the root first, then lanes in folder-name order with each lane's
|
|
/// cards inside it, then `.trash/` — so `BoardLoadFailure.primary` is the first thing the walk
|
|
/// met and a grouped surface reads top-down like the tree does.
|
|
///
|
|
/// - Parameter skipping: **the skip channel** (01-storage-format.md § Malformed input: "Skip is
|
|
/// user-consented tolerance, loudly marked … per-open decisions, never persisted"). Defect
|
|
/// *paths* — the same root-relative strings `BoardLoadError.path` carries, e.g.
|
|
/// `"<lane>/index.md"` — chosen on the decision surface. A skipped path's defect is not
|
|
/// collected and its item leaves the model with its whole subtree, exactly the shape the
|
|
/// tolerated missing-`index.md` skip already has; a `LoadWarning.userSkipped` is the loud mark
|
|
/// the opened board's notice is written from. Nothing persists: the set arrives from one open's
|
|
/// surface and dies with the call.
|
|
///
|
|
/// **Root paths are unskippable** (`unskippablePaths`) — an entry naming the root's own
|
|
/// `index.md` is ignored and the defect collected anyway.
|
|
///
|
|
/// - Parameter memo: **the previous walk's parsed documents** (`ParseMemo`, blessed 2026-07-31).
|
|
/// `nil` — a first load, a template read, a HEAD snapshot — is a cold walk. Passing the last
|
|
/// walk's memo cannot change a single thing about the result, only how many files this one
|
|
/// opens; see `ParseMemo` for the scope and for why a defect can never be answered from it.
|
|
///
|
|
/// - Parameter counter: where this walk tallies what it read (`ParseCounter`). `nil` everywhere
|
|
/// but the suites.
|
|
public static func load(
|
|
boardRoot: URL,
|
|
skipping: Set<String> = [],
|
|
historyRanker: IdentityHistoryRanker? = nil,
|
|
memo: ParseMemo? = nil,
|
|
counter: ParseCounter? = nil
|
|
) throws(BoardLoadFailure) -> LoadResult {
|
|
// Environmental, so immediate: a root that cannot be listed has no walk to collect from.
|
|
do throws(BoardLoadError) {
|
|
try checkIsReadableDirectory(boardRoot)
|
|
} catch {
|
|
throw BoardLoadFailure(error)
|
|
}
|
|
|
|
var warnings: [LoadWarning] = []
|
|
func warn(_ warning: LoadWarning) {
|
|
warnings.append(warning)
|
|
logger.warning("\(warning.description, privacy: .public)")
|
|
}
|
|
|
|
// **The fail-fast aggregate, in walk order** — empty on a board that loads, and the whole of
|
|
// what `BoardLoadFailure` carries when it does not.
|
|
var failures: [BoardLoadError] = []
|
|
|
|
// **This walk's own memo, for the next one** (`ParseMemo`). Built as the walk goes and
|
|
// handed out on the `LoadResult`, so the loader keeps no state between calls: what the store
|
|
// passes back in is what came out of the walk before it.
|
|
var freshMemo = ParseMemo()
|
|
|
|
/// Records one fail-fast defect — unless this open's user already consented to skipping that
|
|
/// exact path.
|
|
///
|
|
/// The item leaves the model either way; what the skip decides is whether the defect is
|
|
/// *reported*. Every caller `continue`s past the item immediately after, which is what makes
|
|
/// "skipped" and "broken" one shape in the walk and two only at the surface.
|
|
func record(_ defect: BoardLoadError) {
|
|
if skipping.contains(defect.path), !unskippablePaths.contains(defect.path) {
|
|
warn(.userSkipped(path: defect.path))
|
|
return
|
|
}
|
|
failures.append(defect)
|
|
logger.error("\(defect.description, privacy: .public)")
|
|
}
|
|
|
|
// **The root index, collected rather than thrown** — and `nil` on either side of it means
|
|
// exactly one thing: a defect was recorded for it above, so the walk below runs for the sake
|
|
// of the *other* defects it can still find and the guard past the walk never lets a
|
|
// rootless board reach `BoardModel`.
|
|
var boardDocument: FrontmatterDocument?
|
|
var boardSchema: Int?
|
|
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
|
|
if FileManager.default.fileExists(atPath: boardIndexURL.path) {
|
|
do throws(BoardLoadError) {
|
|
let read = try memoizedDocument(
|
|
at: boardIndexURL, path: indexFileName, memo: memo, counter: counter)
|
|
// **The root's own `schema` stays required** (01-storage-format.md § Malformed input,
|
|
// re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on
|
|
// the board that does not read as 1 when absent.
|
|
boardSchema = try validatedRootSchema(in: read.document, path: indexFileName)
|
|
boardDocument = read.document
|
|
// Recorded past the schema gate, never before it — `ParseMemo`'s "a defect can never
|
|
// be answered from it".
|
|
freshMemo.record(read.document, at: indexFileName, stamp: read.stamp)
|
|
} catch {
|
|
record(error)
|
|
}
|
|
} else {
|
|
record(BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex))
|
|
}
|
|
|
|
// **The one typed defect stream** (02-architecture.md ▸ Components ▸ IntegrityRules): what
|
|
// this walk found that is pending *work*, as distinct from `warnings`, which is the
|
|
// stray-*tolerance* vocabulary — information, not work. The two ad-hoc repair channels this
|
|
// replaced (loose files, legacy tombstones) are still readable under their own names as
|
|
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
|
|
var defects: [IntegrityRules.Defect] = []
|
|
|
|
// **The coerce tier's trace** (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
|
|
// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries
|
|
// in the integrity service's Defect stream"). Called once per document this walk parses, at
|
|
// every level, because the rule is about *fields* and every level has them — and called here
|
|
// rather than inside `readDocument` for the reason the whole defect stream lives in `load`: the
|
|
// reading functions are pure and total, and the walk is what owns what it found.
|
|
//
|
|
// `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
|
|
// line exists to be findable later rather than to be noticed now ("no banner, no behavior
|
|
// change").
|
|
//
|
|
// `plus` carries the records only the rulebook can make: `order` and `schema` below the root
|
|
// (re-ruled 2026-07-31 — the optional-key readings). They cannot come from
|
|
// `document.coercedFields`, which reads *present* fields that had no sensible reading and by
|
|
// construction knows nothing about a key that is simply absent, nor about which level the
|
|
// file sits at.
|
|
func noteCoercions(
|
|
in document: FrontmatterDocument,
|
|
at path: String,
|
|
plus extra: [CoercedField] = []
|
|
) {
|
|
let fields = extra + document.coercedFields
|
|
guard !fields.isEmpty else { return }
|
|
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
|
|
for field in fields {
|
|
logger.info(
|
|
"\(path, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
|
|
)
|
|
}
|
|
}
|
|
|
|
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
|
|
// the trash read below finds nothing to parse. Read-only here, like every other detection:
|
|
// the displacement is the store's, through the Writer (the Repair precedent).
|
|
if let squatter = IntegrityRules.squattedClaimedName(atBoardRoot: boardRoot) {
|
|
defects.append(.claimedNameSquatted(squatter))
|
|
logger.warning(
|
|
"\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
|
)
|
|
}
|
|
|
|
// Both readings of the root's own document, and both skipped when there is no document to
|
|
// read: a board whose root index is already a collected defect has nothing to say about its
|
|
// `deleted` key or its coercions, and the load is going to throw regardless.
|
|
if let boardDocument {
|
|
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
|
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
|
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
|
|
// deliberately absent from `legacyTombstones`: there is no item to relocate and no key
|
|
// the app has any business removing from a file it was told to leave alone.
|
|
if !boardDocument.deleted.isMissing {
|
|
warn(.boardLevelDeletedIgnored)
|
|
}
|
|
|
|
noteCoercions(in: boardDocument, at: indexFileName)
|
|
}
|
|
|
|
// **The noise gate, read once for the whole walk** (01-storage-format.md § Fractal layout ▸
|
|
// Rules, ruled 2026-07-31): the board's `.gitignore` is what decides which loose files are
|
|
// work and which are ordinary strays, and it is one file per board — not one per card.
|
|
let ignoreRules = ignoreRules(atBoardRoot: boardRoot)
|
|
|
|
// Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide
|
|
// identity dedupe below decides which folders render at all, and a `Lane` is built only on
|
|
// the far side of that decision, because a `Lane` carrying a withheld card would be exactly
|
|
// the snapshot the invariant forbids.
|
|
var walkedLanes: [WalkedLane] = []
|
|
// `try?` because `directoryCandidates` never actually throws — an unlistable folder is "no
|
|
// candidates" by its own rule — and because the two other containers in this file already
|
|
// read it exactly this way (`trashCandidates`, `identityShapedChildren`). Nothing here
|
|
// silences a fail-fast: the root's own listability was decided by `checkIsReadableDirectory`.
|
|
for laneURL in (try? directoryCandidates(in: boardRoot)) ?? [] {
|
|
let laneName = laneURL.lastPathComponent
|
|
// The app-claimed board-root names are not strays and must not warn as such. Hidden
|
|
// entries never reach here anyway (`.trash` included), so this is the rule stated
|
|
// rather than the mechanism relied on.
|
|
guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
|
|
guard isUUIDShaped(laneName) else {
|
|
warn(.nonUUIDFolderIgnored(path: laneName))
|
|
continue
|
|
}
|
|
guard hasIndex(laneURL) else {
|
|
warn(.missingIndex(path: laneName))
|
|
continue
|
|
}
|
|
|
|
let lanePath = laneName + "/" + indexFileName
|
|
let laneDocument: FrontmatterDocument
|
|
let laneSchema: (schema: Int, coerced: CoercedField?)
|
|
let laneStamp: FileStamp?
|
|
// **A broken lane takes its subtree with it** (the collect-and-skip rule above): the
|
|
// defect is recorded, the lane's cards are not enumerated, and the repair's re-check is
|
|
// what surfaces whatever they were hiding.
|
|
do throws(BoardLoadError) {
|
|
let read = try memoizedDocument(
|
|
at: laneURL.appendingPathComponent(indexFileName),
|
|
path: lanePath,
|
|
memo: memo,
|
|
counter: counter
|
|
)
|
|
laneDocument = read.document
|
|
laneStamp = read.stamp
|
|
// Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema`
|
|
// reads as 1, a missing or unusable `order` as append-at-end. Both readings are
|
|
// coerce-tier — recorded here, logged, and acted on by nothing until the file's next
|
|
// Writer touch.
|
|
laneSchema = try resolvedSchema(in: laneDocument, path: lanePath)
|
|
} catch {
|
|
record(error)
|
|
continue
|
|
}
|
|
freshMemo.record(laneDocument, at: lanePath, stamp: laneStamp)
|
|
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
|
|
noteCoercions(
|
|
in: laneDocument,
|
|
at: lanePath,
|
|
plus: [laneSchema.coerced, laneOrder.coerced].compactMap { $0 }
|
|
)
|
|
|
|
var walkedCards: [WalkedCard] = []
|
|
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
|
|
}
|
|
|
|
let card: WalkedCard
|
|
do throws(BoardLoadError) {
|
|
card = try parseCard(at: cardURL, path: cardRelPath, memo: memo, counter: counter)
|
|
} catch {
|
|
record(error)
|
|
continue
|
|
}
|
|
// `parseCard` returning at all means the parse and the `schema` reading both
|
|
// succeeded, which is the recording point one level up spells out longhand.
|
|
freshMemo.record(card.document, at: cardRelPath + "/" + indexFileName, stamp: card.stamp)
|
|
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)
|
|
|
|
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
|
|
// extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing
|
|
// `attachments` breaks every import into this card, every Finder drop on it and the
|
|
// window's listing for as long as it stands, so it is scheduled work exactly as a
|
|
// squatted `.trash` is. Detection is read-only here, like every other defect; the
|
|
// displacement is the store's, through the Writer.
|
|
for squatter in IntegrityRules.squattedClaimedNames(inCardAt: cardURL, path: cardRelPath) {
|
|
defects.append(.claimedNameSquatted(squatter))
|
|
logger.warning(
|
|
"\(cardRelPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
|
)
|
|
}
|
|
|
|
// Noticed, never acted on: the relocation is the store's, through the Writer. What
|
|
// the board's `.gitignore` excludes never becomes a defect at all — it keeps the
|
|
// stray posture, so there is nothing here for the store to schedule and nothing for
|
|
// a banner to announce.
|
|
let loose = looseFileNames(in: cardURL, ignoring: ignoreRules)
|
|
if !loose.isEmpty {
|
|
defects.append(.looseCardFiles(LooseCardFiles(
|
|
laneID: ItemID(rawValue: laneName),
|
|
cardID: ItemID(rawValue: cardName),
|
|
title: card.title.value,
|
|
fileNames: loose
|
|
)))
|
|
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
|
}
|
|
|
|
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
|
// and the key's removal are the store's, through the Writer.
|
|
if card.isDeleted {
|
|
defects.append(.legacyTombstone(LegacyTombstone(
|
|
laneID: ItemID(rawValue: laneName),
|
|
cardID: ItemID(rawValue: cardName),
|
|
title: card.title.value
|
|
)))
|
|
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
|
}
|
|
|
|
walkedCards.append(card)
|
|
}
|
|
|
|
// **The append-at-end reading, settled per container** (01-storage-format.md § Ordering,
|
|
// re-ruled 2026-07-31): every card's rank is the one it wrote down, or — where it wrote
|
|
// none — a materialized rank past every ordered sibling, ordered among the other
|
|
// order-less ones by folder name. Settled here, over the lane's whole card list, because
|
|
// that is the smallest scope in which "past every ordered sibling" is a fact.
|
|
let cardOrders = Ranks.resolvedOrders(
|
|
of: walkedCards, stored: \.storedOrder, name: { $0.id.rawValue })
|
|
let cards = zip(walkedCards, cardOrders).map { $0.rendered(order: $1) }
|
|
|
|
// **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
|
|
// § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
|
|
// live with the key ignored — no migration machinery, no key-strip write, no notice
|
|
// (backward compatibility deliberately not funded …; the key is inert, preserved verbatim
|
|
// like any unhandled key, logged)". A warning, exactly like the board-level key: this was
|
|
// ignored, it is staying exactly where it is, there is nothing to do.
|
|
if !laneDocument.deleted.isMissing {
|
|
warn(.laneLevelDeletedIgnored(path: laneName))
|
|
}
|
|
|
|
walkedLanes.append(WalkedLane(
|
|
name: laneName,
|
|
schema: laneSchema.schema,
|
|
storedOrder: laneOrder.order,
|
|
document: laneDocument,
|
|
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue })
|
|
))
|
|
}
|
|
|
|
// The strip's own append-at-end reading — the card rule one level up, and the reason the
|
|
// lane loop above carried a *stored* order: a lane with no rank sorts past every ranked one,
|
|
// which cannot be known until the last lane folder has been read.
|
|
for (index, order) in Ranks.resolvedOrders(
|
|
of: walkedLanes, stored: \.storedOrder, name: \.name
|
|
).enumerated() {
|
|
walkedLanes[index].order = order
|
|
}
|
|
|
|
/// Every trash entry as the walk read it, kind decided but rank not yet — the `WalkedCard`
|
|
/// intermediate's job in the flat container, in a tuple because the split into the snapshot's
|
|
/// two arrays happens on the far side of the append-at-end reading.
|
|
var walkedTrash: [(
|
|
id: ItemID,
|
|
kind: IntegrityRules.ObjectKind,
|
|
schema: Int,
|
|
storedOrder: Double?,
|
|
heldCards: Int,
|
|
attachments: [String],
|
|
document: FrontmatterDocument
|
|
)] = []
|
|
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
|
for entryURL in trashCandidates(in: boardRoot) {
|
|
let entryName = entryURL.lastPathComponent
|
|
let entryRelPath = trashFolderName + "/" + entryName
|
|
guard isUUIDShaped(entryName) else {
|
|
warn(.nonUUIDFolderIgnored(path: entryRelPath))
|
|
continue
|
|
}
|
|
guard hasIndex(entryURL) else {
|
|
warn(.missingIndex(path: entryRelPath))
|
|
continue
|
|
}
|
|
// The two kinds are read identically — the same optional-`schema`/optional-`order`
|
|
// rulebook below the root — so the parse happens once, before the discriminator, and a
|
|
// schema newer than this app fails fast whichever kind the entry turns out to be.
|
|
let entryPath = entryRelPath + "/" + indexFileName
|
|
let document: FrontmatterDocument
|
|
let schema: (schema: Int, coerced: CoercedField?)
|
|
let stamp: FileStamp?
|
|
// Collected and skipped, the lane arm's rule one container over: a trash entry that will
|
|
// not parse leaves the trash rather than refusing the board, and its own subtree was
|
|
// never walked to begin with (the entry is opaque by design).
|
|
do throws(BoardLoadError) {
|
|
let read = try memoizedDocument(
|
|
at: entryURL.appendingPathComponent(indexFileName),
|
|
path: entryPath,
|
|
memo: memo,
|
|
counter: counter
|
|
)
|
|
document = read.document
|
|
stamp = read.stamp
|
|
schema = try resolvedSchema(in: document, path: entryPath)
|
|
} catch {
|
|
record(error)
|
|
continue
|
|
}
|
|
freshMemo.record(document, at: entryPath, stamp: stamp)
|
|
let order = IntegrityRules.resolvedOrder(in: document)
|
|
noteCoercions(
|
|
in: document,
|
|
at: entryPath,
|
|
plus: [schema.coerced, order.coerced].compactMap { $0 }
|
|
)
|
|
|
|
// **The trash's discriminator, applied where the flat container needs it**
|
|
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
|
// outright, and only an unrecognized value or no key at all falls through to shape.
|
|
//
|
|
// The children are listed at most once per entry and only where an answer needs them —
|
|
// the shape fallback asks when `kind` did not, the lane arm asks for the count — so a
|
|
// board of trashed cards written by this app pays for no directory reads at all.
|
|
var listed: [URL]?
|
|
func children() -> [URL] {
|
|
if let listed { return listed }
|
|
let found = identityShapedChildren(of: entryURL)
|
|
listed = found
|
|
return found
|
|
}
|
|
let kind = IntegrityRules.trashKind(
|
|
kindValue: document.kind.value,
|
|
hasIdentityShapedChildIndex: !children().isEmpty
|
|
)
|
|
let id = ItemID(rawValue: entryName)
|
|
trashKinds[id] = kind
|
|
|
|
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
|
|
// showing its title and held-card count). The count is the same listing the shape
|
|
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and a
|
|
// kindless one pays for none extra — and a card pays for its attachment listing only,
|
|
// which is why each side is read under its own arm rather than unconditionally.
|
|
//
|
|
// Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` treats
|
|
// both as unrecognized and answers by shape — so the non-lane arm is the card answer and
|
|
// nothing else.
|
|
let isLane = kind == .lane
|
|
walkedTrash.append((
|
|
id: id,
|
|
kind: kind,
|
|
schema: schema.schema,
|
|
storedOrder: order.order,
|
|
heldCards: isLane ? children().count : 0,
|
|
attachments: isLane ? [] : attachmentNames(in: entryURL),
|
|
document: document
|
|
))
|
|
}
|
|
|
|
// **The walk is over, so the aggregate is complete.** Everything below this line assembles a
|
|
// snapshot, and a board with a fail-fast defect in it has no snapshot to assemble — so the
|
|
// throw sits exactly here: late enough that the surface gets every defect the tree holds,
|
|
// early enough that a refused board pays for no dedupe, no sort and no `BoardModel`.
|
|
guard failures.isEmpty else { throw BoardLoadFailure(failures) }
|
|
|
|
// The trash's own append-at-end reading, over the container as one flat list. `order` decides
|
|
// nothing about where a trash row *sits* — that is `modified`'s job since 2026-07-31 — but
|
|
// every entry carries a rank for its eventual restore, and an entry that carries none reads
|
|
// like every other order-less file rather than getting a container-specific rule of its own.
|
|
var trash: [Card] = []
|
|
var trashedLanes: [TrashedLane] = []
|
|
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
|
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
|
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
|
/// sorted by `modified` descending since 2026-07-31.
|
|
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
|
let trashOrders = Ranks.resolvedOrders(
|
|
of: walkedTrash, stored: { $0.storedOrder }, name: { $0.id.rawValue })
|
|
for (entry, order) in zip(walkedTrash, trashOrders) {
|
|
let document = entry.document
|
|
trashEntries.append((
|
|
id: entry.id, title: document.title.value, modified: document.modified.value))
|
|
switch entry.kind {
|
|
case .lane:
|
|
trashedLanes.append(TrashedLane(
|
|
id: entry.id,
|
|
schema: entry.schema,
|
|
title: document.title,
|
|
modified: document.modified,
|
|
order: order,
|
|
heldCards: entry.heldCards,
|
|
document: document
|
|
))
|
|
case .card, .board, .comment:
|
|
trash.append(Card(
|
|
id: entry.id,
|
|
schema: entry.schema,
|
|
title: document.title,
|
|
created: document.created,
|
|
modified: document.modified,
|
|
modifiedBy: document.modifiedBy,
|
|
deleted: document.deleted,
|
|
background: document.background,
|
|
icon: document.icon,
|
|
iconColor: document.iconColor,
|
|
order: order,
|
|
attachments: entry.attachments,
|
|
document: document
|
|
))
|
|
}
|
|
}
|
|
|
|
// **The board-wide identity dedupe** (01-storage-format.md § Fractal layout ▸ Rules:
|
|
// "Duplicate ids within a board are never tolerated … the loader keeps exactly one occurrence
|
|
// per id, board-wide"). Live lanes *and* `.trash/`, because board-wide means both containers —
|
|
// and a snapshot carrying two items with equal ids is the one thing SwiftUI's `ForEach` does
|
|
// not tolerate, which is why this is subtractive rather than advisory.
|
|
//
|
|
// Display order is settled here rather than in the `BoardModel` call below, because the
|
|
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
|
|
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
|
|
// **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md
|
|
// § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the
|
|
// position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries`
|
|
// applies, which is what makes a kind-narrowed slice of the column agree with the column.
|
|
let orderedTrash = Ranks.sortedForTrash(
|
|
trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
|
|
let orderedTrashedLanes = Ranks.sortedForTrash(
|
|
trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
|
|
// The trash's own display order, **both kinds at once** — the column interleaves them
|
|
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
|
|
// the two kinds are merged before the rule sees them rather than after.
|
|
let orderedTrashEntries = Ranks.sortedForTrash(
|
|
trashEntries, modified: \.modified, title: \.title, name: { $0.id.rawValue })
|
|
let verdict = dedupeIdentities(
|
|
inBoardAt: boardRoot,
|
|
lanes: orderedLanes,
|
|
trash: orderedTrashEntries,
|
|
historyRanker: historyRanker
|
|
)
|
|
|
|
// Case twins are the **tolerate** tier: a spelling artifact of the item that rendered, with
|
|
// nothing to do about it (§ Fractal layout ▸ Rules). They warn, exactly like every other
|
|
// skipped stray, and are deliberately absent from `defects`.
|
|
for twin in verdict.caseTwins {
|
|
warn(.caseTwinIgnored(path: twin.path, winner: twin.winner))
|
|
}
|
|
// Content duplicates are **work**: withheld here, reminted by the store's scheduled heal
|
|
// (re-ruled 2026-07-29 — the loader itself still never writes).
|
|
for duplicate in verdict.duplicates {
|
|
defects.append(.duplicateIdentity(duplicate))
|
|
logger.warning(
|
|
"\(duplicate.path, privacy: .public): duplicate id, withheld in favour of \(duplicate.winner, privacy: .public) — to be reminted"
|
|
)
|
|
}
|
|
|
|
let withheld = Set(verdict.duplicates.map(\.path) + verdict.caseTwins.map(\.path))
|
|
|
|
// Unreachable, and spelled out rather than force-unwrapped: every path that leaves these
|
|
// unset recorded a defect, and the guard above already threw on any defect at all. The
|
|
// stated invariant is "no root document, no board" — a future edit that breaks it should
|
|
// surface as the honest fail-fast rather than as a crash.
|
|
guard let boardDocument, let boardSchema else {
|
|
throw BoardLoadFailure(BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex))
|
|
}
|
|
|
|
let model = BoardModel(
|
|
rootURL: boardRoot,
|
|
schema: boardSchema,
|
|
title: boardDocument.title,
|
|
created: boardDocument.created,
|
|
modified: boardDocument.modified,
|
|
modifiedBy: boardDocument.modifiedBy,
|
|
deleted: boardDocument.deleted,
|
|
background: boardDocument.background,
|
|
backgroundImage: boardDocument.backgroundImage,
|
|
icon: boardDocument.icon,
|
|
iconColor: boardDocument.iconColor,
|
|
template: boardDocument.value(for: templateKey),
|
|
lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
|
|
trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
|
trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
|
document: boardDocument
|
|
)
|
|
|
|
return LoadResult(
|
|
model: model,
|
|
warnings: warnings,
|
|
memo: freshMemo,
|
|
defects: defects,
|
|
// Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing
|
|
// an id would otherwise leave a `kind` answer standing for the *other* one — the exact
|
|
// ambiguity the dedupe exists to remove.
|
|
trashKinds: trashKinds.filter { !withheld.contains(trashFolderName + "/" + $0.key.rawValue) }
|
|
)
|
|
}
|
|
|
|
// MARK: - The board-wide identity dedupe
|
|
|
|
/// Every identity-bearing folder in the board, deduped — the loader's half of the rule whose
|
|
/// *deciding* is `IntegrityRules.dedupe(_:)`.
|
|
///
|
|
/// **The gate comes first, and it is why this costs nothing on a healthy board**: a pass over the
|
|
/// names alone answers "does any identity appear twice at all", and on the overwhelmingly common
|
|
/// answer — no — nothing further happens: no `creationDateKey` reads, no history probes, no
|
|
/// grouping. The disk reads below are paid for only by a board that actually has a collision.
|
|
///
|
|
/// **Withheld subtrees are still walked** (settled — the import boundary's finest-grain rule read
|
|
/// for the heal): a withheld *lane*'s cards are ordinary depth-2 occurrences and enter the
|
|
/// inventory like any other, so a collision nested inside a losing lane is its own withheld
|
|
/// occurrence with its own remint. Nothing deeper is an occurrence at all — "level is position",
|
|
/// and a UUID-shaped folder under a card is content.
|
|
///
|
|
/// **The container side is told to the rule, not derived from the path**: this walk knows which
|
|
/// container it is in, and the container boundary is the rule's *first* tie-break (01-storage-format.md
|
|
/// § Fractal layout ▸ Rules, stated 2026-07-29 — a live occurrence keeps the identity regardless of
|
|
/// age), so re-deriving it from a `".trash/"` prefix downstream would be parsing back a fact that
|
|
/// was in hand here.
|
|
///
|
|
/// Occurrence order is the traversal order the rule's *last* tie-break is stated in: lanes in
|
|
/// display order, each lane immediately followed by its cards in display order, then `.trash/`'s
|
|
/// flat entries in display order. The trash sits last only because the walk meets it last —
|
|
/// nothing rides on that any more, because the straddle case is decided by rung 0 long before
|
|
/// traversal order is consulted.
|
|
private static func dedupeIdentities(
|
|
inBoardAt root: URL,
|
|
lanes: [WalkedLane],
|
|
trash: [(id: ItemID, title: String?, modified: Date?)],
|
|
historyRanker: IdentityHistoryRanker?
|
|
) -> IntegrityRules.DedupeVerdict {
|
|
typealias Container = IntegrityRules.IdentityOccurrence.Container
|
|
// (path, name, container, title) in traversal order — the inventory, before anything is read
|
|
// from disk.
|
|
var inventory: [(path: String, name: String, container: Container, title: String?)] = []
|
|
for lane in lanes {
|
|
inventory.append((
|
|
path: lane.name,
|
|
name: lane.name,
|
|
container: .live,
|
|
title: lane.document.title.value
|
|
))
|
|
for card in lane.cards {
|
|
inventory.append((
|
|
path: lane.name + "/" + card.id.rawValue,
|
|
name: card.id.rawValue,
|
|
container: .live,
|
|
title: card.title.value
|
|
))
|
|
}
|
|
}
|
|
for entry in trash {
|
|
inventory.append((
|
|
path: trashFolderName + "/" + entry.id.rawValue,
|
|
name: entry.id.rawValue,
|
|
container: .trashed,
|
|
title: entry.title
|
|
))
|
|
}
|
|
|
|
// The gate.
|
|
var seen: Set<String> = []
|
|
var collides = false
|
|
for entry in inventory where !seen.insert(IntegrityRules.canonicalIdentity(entry.name)).inserted {
|
|
collides = true
|
|
break
|
|
}
|
|
guard collides else { return IntegrityRules.DedupeVerdict(duplicates: [], caseTwins: []) }
|
|
|
|
return IntegrityRules.dedupe(inventory.map { entry in
|
|
IntegrityRules.IdentityOccurrence(
|
|
path: entry.path,
|
|
name: entry.name,
|
|
container: entry.container,
|
|
title: entry.title,
|
|
birth: birthDate(of: root.appendingPathComponent(entry.path, isDirectory: true)),
|
|
historyRank: historyRanker?.rank(entry.path)
|
|
)
|
|
})
|
|
}
|
|
|
|
/// A folder's filesystem birth date, `nil` when the volume does not keep one or the read fails —
|
|
/// the second rung of the earlier-occurrence-wins ladder (§ Fractal layout ▸ Rules: "without
|
|
/// history, the older folder (filesystem birth date) wins").
|
|
///
|
|
/// `nil` is a first-class answer rather than a fallback date: an unreadable birth date must drop
|
|
/// the comparison to traversal order, and substituting `.distantPast` here would silently make an
|
|
/// unreadable folder *win* every comparison it entered.
|
|
private static func birthDate(of folder: URL) -> Date? {
|
|
(try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate
|
|
}
|
|
|
|
/// One lane as the walk read it, before the dedupe decided what renders — a `Lane` minus the
|
|
/// decision, which is the only reason it exists rather than the walk building `Lane` values
|
|
/// directly.
|
|
///
|
|
/// It carries the folder *name* rather than an `ItemID` because the dedupe's whole subject is
|
|
/// spelling and identity being different questions, and the verbatim name is what answers both.
|
|
private struct WalkedLane {
|
|
let name: String
|
|
let schema: Int
|
|
/// The rank the file actually carries, `nil` where it carries none this app can use.
|
|
let storedOrder: Double?
|
|
/// The append-at-end reading, filled in once every sibling lane has been read
|
|
/// (`Ranks.resolvedOrders(of:stored:name:)` — 01-storage-format.md § Ordering, re-ruled
|
|
/// 2026-07-31). A `var` on a private walk value for the same reason the type exists at all:
|
|
/// the container settles it, and the `Lane` is built on the far side of that.
|
|
var order: Double = 0
|
|
let document: FrontmatterDocument
|
|
/// Already in display order — the traversal the dedupe's last tie-break is stated in.
|
|
let cards: [Card]
|
|
|
|
/// The `Lane` this becomes, or `nil` when the lane folder itself is withheld — a withheld
|
|
/// lane takes its subtree out of the snapshot with it, and its cards' own collisions were
|
|
/// already decided (they are occurrences in their own right).
|
|
func rendered(withholding withheld: Set<String>) -> Lane? {
|
|
guard !withheld.contains(name) else { return nil }
|
|
return Lane(
|
|
id: ItemID(rawValue: name),
|
|
schema: schema,
|
|
title: document.title,
|
|
created: document.created,
|
|
modified: document.modified,
|
|
modifiedBy: document.modifiedBy,
|
|
deleted: document.deleted,
|
|
background: document.background,
|
|
icon: document.icon,
|
|
iconColor: document.iconColor,
|
|
order: order,
|
|
width: document.width,
|
|
collapsed: document.collapsed,
|
|
cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) },
|
|
document: document
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - The earlier-occurrence-wins history seam
|
|
|
|
/// **Where git path history once plugged into the duplicate-id winner rule** (01-storage-format.md
|
|
/// § Fractal layout ▸ Rules: "on git boards, the path history already tracks outranks the
|
|
/// newcomer (both tracked: the path that entered history first)") — kept as a seam, unfilled,
|
|
/// since app-managed git was excised entirely (`strategy/01-git-excision.md`, 2026-08-08).
|
|
///
|
|
/// A seam rather than an implementation because the first rung of that ladder was unreachable
|
|
/// without git even before the excision: the free tier ran no git machinery at all
|
|
/// (12-editions.md ▸ The inert posture), so the loader consults an injected ranker and falls
|
|
/// through to birth date and traversal order when there is none — which is every board today, no
|
|
/// exceptions.
|
|
///
|
|
/// Deliberately one closure and no protocol: the loader asks one question — "how early did this
|
|
/// path enter history" — and pro-m1's implementation once answered it from `git log
|
|
/// --diff-filter=A --follow`-shaped plumbing behind the provider seam, before that plumbing left
|
|
/// with the excision. `nil` means "untracked, or no history here", which the rule reads as
|
|
/// *outranked by anything tracked*; nothing installs a ranker today, so every path reads `nil`.
|
|
///
|
|
/// - Parameter rank: keyed by the occurrence's **board-root-relative path**, which is what a
|
|
/// repo's path history knows; lower is earlier.
|
|
public struct IdentityHistoryRanker: Sendable {
|
|
public let rank: @Sendable (String) -> Int?
|
|
|
|
public init(rank: @escaping @Sendable (String) -> Int?) {
|
|
self.rank = rank
|
|
}
|
|
}
|
|
|
|
/// A folder's **identity-shaped children holding their own `index.md`** — the cards a trash
|
|
/// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion:
|
|
/// "UUID-shaped children with their own `index.md` → lane … else card"; 03-board-ui.md § Trash:
|
|
/// the row's held-card count).
|
|
///
|
|
/// Deliberately not a parse: this asks what the folder *holds*, not whether anything inside it
|
|
/// would load. A trashed lane's cards are never enumerated as levels — the walk stops at a trash
|
|
/// entry exactly as it stops at a card under a lane — so the count is a fact about the freight
|
|
/// and the entry stays opaque.
|
|
private static func identityShapedChildren(of folder: URL) -> [URL] {
|
|
let children = (try? directoryCandidates(in: folder)) ?? []
|
|
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
|
}
|
|
|
|
/// One card as the walk read it, before its container's ranks resolved — a `Card` minus the
|
|
/// append-at-end reading, which cannot be settled until every sibling's stored `order` is known
|
|
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31). `WalkedLane`'s shape, one level down
|
|
/// and for the same kind of reason: a value the container decides is not a value the item can
|
|
/// carry while it is still being read.
|
|
private struct WalkedCard {
|
|
let id: ItemID
|
|
let schema: Int
|
|
/// The rank the file actually carries, `nil` where it carries none this app can use — the
|
|
/// input to `Ranks.resolvedOrders(of:stored:name:)`.
|
|
let storedOrder: Double?
|
|
let attachments: [String]
|
|
let document: FrontmatterDocument
|
|
/// This card's coerce-tier records for the strict fields, which only the rulebook can make
|
|
/// (a missing key leaves no trace in `document.coercedFields`).
|
|
let coercions: [CoercedField]
|
|
/// What this card's `index.md` looked like to `stat(2)` as the walk read it — the key the
|
|
/// next walk's memo hit is decided by, `nil` where the file could not be stat'd at all.
|
|
let stamp: FileStamp?
|
|
|
|
var title: FieldValue<String> { document.title }
|
|
var isDeleted: Bool { !document.deleted.isMissing }
|
|
|
|
func rendered(order: Double) -> Card {
|
|
Card(
|
|
id: id,
|
|
schema: schema,
|
|
title: document.title,
|
|
created: document.created,
|
|
modified: document.modified,
|
|
modifiedBy: document.modifiedBy,
|
|
deleted: document.deleted,
|
|
background: document.background,
|
|
icon: document.icon,
|
|
iconColor: document.iconColor,
|
|
order: order,
|
|
attachments: attachments,
|
|
document: document
|
|
)
|
|
}
|
|
}
|
|
|
|
/// One card folder read into a `WalkedCard` — the lane walk's card parse.
|
|
///
|
|
/// The trash's own walk reads its entries inline instead, because the container is flat and its
|
|
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their
|
|
/// `schema`/`order` rulebook (`IntegrityRules`, one rule) rather than sharing a function that
|
|
/// has already decided what it is reading.
|
|
///
|
|
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
|
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
|
|
///
|
|
/// The **attachment listing stays fresh** here, memo or no memo (`ParseMemo` ▸ Scope): a hit
|
|
/// spares this card's `index.md` read and nothing else, because an attachment arriving in
|
|
/// `attachments/` never touches `index.md` and a card whose paperclip went stale would be the
|
|
/// memo lying about the tree.
|
|
private static func parseCard(
|
|
at cardURL: URL,
|
|
path: String,
|
|
memo: ParseMemo?,
|
|
counter: ParseCounter?
|
|
) throws(BoardLoadError) -> WalkedCard {
|
|
let cardPath = path + "/" + indexFileName
|
|
let read = try memoizedDocument(
|
|
at: cardURL.appendingPathComponent(indexFileName),
|
|
path: cardPath,
|
|
memo: memo,
|
|
counter: counter
|
|
)
|
|
let document = read.document
|
|
let schema = try resolvedSchema(in: document, path: cardPath)
|
|
let order = IntegrityRules.resolvedOrder(in: document)
|
|
|
|
return WalkedCard(
|
|
id: ItemID(rawValue: cardURL.lastPathComponent),
|
|
schema: schema.schema,
|
|
storedOrder: order.order,
|
|
attachments: attachmentNames(in: cardURL),
|
|
document: document,
|
|
coercions: [schema.coerced, order.coerced].compactMap { $0 },
|
|
stamp: read.stamp
|
|
)
|
|
}
|
|
|
|
/// The candidate entry folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
|
///
|
|
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
|
|
/// boards never have one, and a board without a trash is a board with an empty trash.
|
|
///
|
|
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
|
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
|
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
|
/// reports. Not a `LoadWarning`: that is the stray vocabulary, and a claimed name is not a
|
|
/// stray. Since 2026-07-29 it is not merely logged either — the walk reports it as a
|
|
/// `Defect.claimedNameSquatted` (detected up in `load`, before the lanes) and a scheduled heal
|
|
/// displaces it. Until that heal lands the loader keeps this empty-trash read, which is exactly
|
|
/// the "window measured in one reload, not a standing state" the ruling accepts.
|
|
///
|
|
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
|
/// folder-name order — so the trash gets the same stray tolerance every other container gets.
|
|
/// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry
|
|
/// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque
|
|
/// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked.
|
|
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
|
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
|
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
|
return []
|
|
}
|
|
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
|
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash until the heal displaces it")
|
|
return []
|
|
}
|
|
return (try? directoryCandidates(in: trashURL)) ?? []
|
|
}
|
|
|
|
// MARK: - Filesystem helpers
|
|
|
|
private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) {
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
|
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))
|
|
}
|
|
guard isDirectory.boolValue else {
|
|
throw BoardLoadError(path: ".", reason: .notADirectory)
|
|
}
|
|
}
|
|
|
|
private static func hasIndex(_ folder: URL) -> Bool {
|
|
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
|
|
}
|
|
|
|
/// The names of `<card>/attachments/`'s **top-level regular files** — the flat view
|
|
/// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
|
|
/// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`.
|
|
///
|
|
/// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
|
|
/// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
|
|
/// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
|
|
/// never appears as an attachment), and symlinks (this loader resolves nothing — the same
|
|
/// stance the level walk takes).
|
|
///
|
|
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
|
|
/// `"shot 10.png"`: the order has to be stable across loads, and where it is already the
|
|
/// sidebar's order it may as well be the same one.
|
|
///
|
|
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
|
|
/// structure (01-storage-format.md § Malformed input), and this field decorates a card — a
|
|
/// permissions race here must never be the reason a whole board refuses to open.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's
|
|
/// authoritative listing — answers through this same function behind its own card-folder
|
|
/// guard, so the face and the sidebar can never disagree about what a card's attachments are.
|
|
/// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
|
|
/// the app ever creates under a card.
|
|
static func attachmentNames(in cardFolder: URL) -> [String] {
|
|
let folder = cardFolder.appendingPathComponent(
|
|
BoardWriter.attachmentsFolderName, isDirectory: true
|
|
)
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: folder,
|
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return entries
|
|
.filter { url in
|
|
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
|
return false
|
|
}
|
|
return values.isRegularFile == true && values.isSymbolicLink != true
|
|
}
|
|
.map(\.lastPathComponent)
|
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
/// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"):
|
|
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
|
|
/// belongs in `attachments/`, and the app moves it there".
|
|
///
|
|
/// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
|
|
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
|
|
/// is a pure function of the tree and stays one.
|
|
///
|
|
/// Five exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
|
|
///
|
|
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
|
|
/// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
|
|
/// into the flat attachment model would be wrong".
|
|
/// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk
|
|
/// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
|
|
/// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
|
|
/// own account.
|
|
/// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
|
|
/// would surface it in a card's attachment list — the loudest possible way to be wrong about
|
|
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
|
|
/// residue out of the relocation.
|
|
///
|
|
/// **This survived the noise-gate ruling** (2026-07-31), which retired "nothing hardcoded"
|
|
/// exclusions in favour of the seeded `.gitignore`, because it is not a noise heuristic and
|
|
/// never was a `.DS_Store` special case: `attachmentNames(in:)` skips hidden entries too, so a
|
|
/// relocated hidden file would land in a folder whose listing can never show it — the move
|
|
/// would take a file the user can see in Finder beside `index.md` and put it somewhere the app
|
|
/// is structurally unable to surface. The carve-out exists to put a card's files where the app
|
|
/// *shows* them; a hidden file has no such destination. What the ruling retires is the app
|
|
/// having a second opinion about *which* visible files are noise, and it never had one.
|
|
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
|
|
/// - **Whatever the board's `.gitignore` excludes** (§ Rules, ruled 2026-07-31 — the noise
|
|
/// gate): "a file matching the board-root `.gitignore` … keeps the ordinary stray posture:
|
|
/// skipped, preserved verbatim, logged, never relocated, never announced". Matched against the
|
|
/// file's **board-relative** path (`<lane>/<card>/<name>`), because that is the path git would
|
|
/// match and because an anchored pattern (`/notes.txt`) has to mean the board root rather than
|
|
/// every card in it. `rules` is `nil` on a board that carries no such file, which excludes
|
|
/// nothing — the pre-ruling behaviour, and the same answer an empty file gives.
|
|
///
|
|
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
|
|
/// the store posts names files the way the board would sort them.
|
|
///
|
|
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
|
|
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
|
|
/// `rules` has no default on purpose: the gate is the ruling, and a caller that could forget to
|
|
/// pass it would be a second, quieter definition of what counts as noise.
|
|
static func looseFileNames(in cardFolder: URL, ignoring rules: GitignoreRules?) -> [String] {
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: cardFolder,
|
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
let cardPath = boardRelativeCardPath(of: cardFolder)
|
|
return entries
|
|
.filter { url in
|
|
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
|
|
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
|
|
values.isRegularFile == true, values.isSymbolicLink != true
|
|
else {
|
|
return false
|
|
}
|
|
// The noise gate. Logged rather than silent, because "skipped, preserved verbatim,
|
|
// logged" is the stray posture this file is being *given*, and a file that stays put
|
|
// for a reason nobody can see is the one the user files a bug about.
|
|
let name = url.lastPathComponent
|
|
guard rules?.isIgnored(relativePath: cardPath + "/" + name) == true else { return true }
|
|
logger.info(
|
|
"\(cardPath, privacy: .public)/\(name, privacy: .public): matched by the board's \(gitignoreFileName, privacy: .public) — left where it is, not relocated"
|
|
)
|
|
return false
|
|
}
|
|
.map(\.lastPathComponent)
|
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
/// A card folder's board-relative path — `<lane>/<card>` — read off the URL rather than passed
|
|
/// in, so the one caller that has no walk behind it (the Writer's import-boundary normalization)
|
|
/// gets the same answer the walk would have given.
|
|
///
|
|
/// The depth is fixed by the schema: a card is `<root>/<lane>/<card>` and nothing else
|
|
/// (`BoardWriter.checkIsCardFolder` enforces exactly this shape before any relocation), so two
|
|
/// components are the whole of it.
|
|
private static func boardRelativeCardPath(of cardFolder: URL) -> String {
|
|
cardFolder.deletingLastPathComponent().lastPathComponent + "/" + cardFolder.lastPathComponent
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **The rule itself is `IntegrityRules.isIdentityShaped`** (settled 2026-07-29 — the one
|
|
/// vocabulary of object validity). This is the loader's spelling of it and nothing more: one
|
|
/// predicate, one implementation, no parallel derivation.
|
|
static func isUUIDShaped(_ name: String) -> Bool {
|
|
IntegrityRules.isIdentityShaped(name)
|
|
}
|
|
|
|
/// 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 at all, and the caller further narrows
|
|
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
|
|
/// anything else with them.
|
|
///
|
|
/// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is
|
|
/// exactly right: it is a container, not a level, and the walk reaches it by name instead
|
|
/// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
|
|
/// children get identical stray tolerance.
|
|
///
|
|
/// 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
|
|
/// root and for malformed `index.md` content, not transient directory-listing races below
|
|
/// it.
|
|
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates
|
|
/// siblings through this same door, so the writer's idea of "the children" can never drift
|
|
/// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the
|
|
/// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load.
|
|
static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] {
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: folder,
|
|
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return entries
|
|
.filter { url in
|
|
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
|
return false
|
|
}
|
|
return values.isDirectory == true && values.isSymbolicLink != true
|
|
}
|
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
|
}
|
|
|
|
// MARK: - Document reading + field validation
|
|
|
|
/// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same
|
|
/// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a
|
|
/// leading BOM, which would let a BOM'd file *load* here and then refuse every write over
|
|
/// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is
|
|
/// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
|
|
/// decoding byte-faithfully is what makes that stated rejection actually happen.
|
|
private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: url)
|
|
} catch {
|
|
throw BoardLoadError(
|
|
path: path,
|
|
reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
|
|
)
|
|
}
|
|
return try parseDocument(data, path: path)
|
|
}
|
|
|
|
/// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL.
|
|
///
|
|
/// Split out for the raw-source outlet, which validates bytes that are **not on disk yet**
|
|
/// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the
|
|
/// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if
|
|
/// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd
|
|
/// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules).
|
|
static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
|
guard let text = String(validating: data, as: UTF8.self) else {
|
|
throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
|
|
}
|
|
|
|
do {
|
|
return try FrontmatterDocument.parse(text)
|
|
} catch {
|
|
let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil }
|
|
throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line))
|
|
}
|
|
}
|
|
|
|
/// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate
|
|
/// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
|
|
/// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
|
|
///
|
|
/// **Exactly the checks `load(boardRoot:)` runs on a card**, in its order and through its own
|
|
/// functions: decode + parse (`parseDocument`), then `schema` at the below-the-root reading
|
|
/// (missing reads as 1; malformed, or newer than this app, still refuses). Nothing card-shaped
|
|
/// is checked beyond that, because nothing else *is*: `title` is optional, **`order` is optional
|
|
/// too** since 2026-07-31 — a card applied without one lands at its lane's bottom and gains a
|
|
/// rank on its next touch — unknown keys are the whole point of the outlet, and the body is free
|
|
/// text.
|
|
///
|
|
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
|
|
/// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
|
|
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
|
|
///
|
|
/// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text
|
|
/// (line numbers included) rather than a re-worded copy.
|
|
///
|
|
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
|
|
/// what the card window's alert is about.
|
|
///
|
|
/// **The rule is `IntegrityRules.validateIndex(_:path:kind:supportedSchema:)`**, generalized per
|
|
/// kind (02-architecture.md ▸ Components). This spelling stays because it is what the card
|
|
/// window asks — "would this load as a card?" — and because pinning the kind at the call site is
|
|
/// what keeps the outlet's gate from drifting when a second kind gains one.
|
|
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
|
try IntegrityRules.validateIndex(data, path: path, kind: .card, supportedSchema: supportedSchema)
|
|
}
|
|
|
|
/// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸
|
|
/// Components). These forward so the walk above reads as it always did.
|
|
private static func validatedRootSchema(
|
|
in document: FrontmatterDocument, path: String
|
|
) throws(BoardLoadError) -> Int {
|
|
try IntegrityRules.validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
|
|
}
|
|
|
|
/// `schema` below the root — missing reads as 1, coerce-tier (re-ruled 2026-07-31).
|
|
private static func resolvedSchema(
|
|
in document: FrontmatterDocument, path: String
|
|
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
|
|
try IntegrityRules.resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
|
}
|
|
}
|
|
|
|
// MARK: - Result
|
|
|
|
/// A successful load: the snapshot, anything tolerated-but-notable encountered along the way, and
|
|
/// the pending work the walk found. `warnings` is also logged as it accumulates
|
|
/// (`os.Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")`) so it shows up in Console
|
|
/// even if a caller never inspects it.
|
|
public struct LoadResult: Sendable {
|
|
public var model: BoardModel
|
|
public var warnings: [LoadWarning]
|
|
|
|
/// **What this walk parsed, ready to be the next walk's memo** (`BoardLoader.ParseMemo`, blessed
|
|
/// 2026-07-31).
|
|
///
|
|
/// It rides out here rather than being derived from `model` for two reasons. The stamps are not
|
|
/// in the snapshot and never will be — mtime and size are facts about files, not about a board —
|
|
/// and the documents that *are* in the snapshot would have to be re-indexed by path to be usable,
|
|
/// which is the walk's own knowledge being thrown away and re-derived. Carrying both together
|
|
/// keeps the loader a pure function whose caller holds the whole of what the next call may reuse.
|
|
///
|
|
/// A caller that ignores it gets a cold walk every time, which is exactly what
|
|
/// `TemplateEngine` and every first load do.
|
|
public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo()
|
|
|
|
/// **The typed defect stream** — everything this walk found that is pending *work*
|
|
/// (02-architecture.md ▸ Components ▸ IntegrityRules, settled 2026-07-29). One channel, not
|
|
/// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
|
|
/// wrong kind of node all classify as `IntegrityRules.Defect`, carry their own signature, and
|
|
/// are healed by the one engine (`HealScheduler`).
|
|
///
|
|
/// **Deliberately not `warnings`**, which stays the *tolerate*-tier vocabulary: "this was
|
|
/// ignored, it is staying exactly where it is, there is nothing to do". A defect says the
|
|
/// opposite — it is work, and the store acts on it. Folding the two would also throw away
|
|
/// everything a heal needs (which lane, which card, which title, which names) and force it to be
|
|
/// re-derived from a display string.
|
|
///
|
|
/// Nothing renders this. Order is the walk's: the board root's claimed names, then, lane by
|
|
/// lane, each lane's cards and then the lane itself.
|
|
///
|
|
/// **Cards in `.trash/` are deliberately not walked for loose files in this version.** The
|
|
/// loose-file defect is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a
|
|
/// trashed card has no lane; widening the key is the store-side change that belongs with the
|
|
/// store-side scheduling. Loose files beside a trashed card's `index.md` therefore keep the
|
|
/// ordinary stray posture — tolerated, preserved verbatim — and are tidied the moment the card
|
|
/// is restored into a lane, which is the only state in which they matter.
|
|
///
|
|
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
|
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
|
public var defects: [IntegrityRules.Defect] = []
|
|
|
|
/// What each `.trash/` entry **is**, by the trash's own discriminator
|
|
/// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
|
|
/// `kind` value trusted outright, falling through to shape only when it does not answer.
|
|
///
|
|
/// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries
|
|
/// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out
|
|
/// of the load as well because it is the *verdict* rather than its consequence — a suite pins
|
|
/// the discriminator directly, and a consumer asking "what did this entry read as" gets the
|
|
/// answer without inferring it from which array the entry landed in. Keyed by identity, so it
|
|
/// survives the display sort.
|
|
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
|
|
|
/// The cards this walk found holding loose files — a **view over `defects`**, under the name it
|
|
/// has always had (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
|
|
///
|
|
/// In the order the walk met them, which is the order the relocation writes them in.
|
|
public var looseCardFiles: [LooseCardFiles] {
|
|
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
|
}
|
|
|
|
/// The legacy `deleted:` keys this walk found on **cards** — the retired tombstone model's
|
|
/// surviving migration input, as a **view over `defects`** (01-storage-format.md § Deletion).
|
|
///
|
|
/// A lane's key is not here and never will be: it is inert, tolerated, and reported as a
|
|
/// `LoadWarning.laneLevelDeletedIgnored` (lane clause re-ruled 2026-07-29). Order is the walk's,
|
|
/// lane by lane.
|
|
public var legacyTombstones: [LegacyTombstone] {
|
|
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
|
}
|
|
|
|
/// The claimed board-root name found held by the wrong kind of node, if any — a **view over
|
|
/// `defects`** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
|
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
|
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
|
|
}
|
|
|
|
/// The later occurrences this walk withheld from `model` — a **view over `defects`**
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules; re-ruled 2026-07-29 — the silent remint).
|
|
///
|
|
/// **The one defect the snapshot is already missing.** Every other defect names something that is
|
|
/// both on disk and in the model; a withheld duplicate is on disk and deliberately *not* in the
|
|
/// model, which is what makes the one-item-per-id invariant hold by construction rather than by
|
|
/// hope. In traversal order — which is the order the remint writes them and the notice names them.
|
|
public var duplicateIdentities: [DuplicateIdentity] {
|
|
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
|
|
}
|
|
|
|
/// Every file whose lenient fields fell back to their defaults — a **view over `defects`**
|
|
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29).
|
|
///
|
|
/// **Nothing consumes it, and that is the point**: the coerce tier changes no behavior, so this is
|
|
/// the observability handle — a suite pins it, a developer reads the log lines it produced, and the
|
|
/// day a shape shows up often enough to deserve a heuristic heal, this is where the evidence
|
|
/// already is. In walk order: the board, then each lane and its cards, then the trash.
|
|
public var coercedFrontmatter: [CoercedFrontmatter] {
|
|
defects.compactMap { if case let .coercedFrontmatter(work) = $0 { work } else { nil } }
|
|
}
|
|
}
|
|
|
|
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
|
|
/// for what does.
|
|
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
|
/// 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 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
|
|
/// 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
|
|
|
|
/// A **lane** carrying a legacy `deleted:` key (01-storage-format.md § Deletion, lane clause
|
|
/// re-ruled 2026-07-29): the lane loads live and the key is ignored — "no migration machinery,
|
|
/// no key-strip write, no notice", preserved verbatim like any unhandled key.
|
|
///
|
|
/// Its home is here rather than in the defect stream because that is exactly the tolerate tier's
|
|
/// verdict on it: this was ignored, it is staying exactly where it is, there is nothing to do.
|
|
/// A *card*'s key is still work and still a `LegacyTombstone` defect — the two halves of the old
|
|
/// migration parted company with the ruling. `path` is relative to the board root.
|
|
case laneLevelDeletedIgnored(path: String)
|
|
|
|
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity —
|
|
/// one item typed two ways (01-storage-format.md § Fractal layout ▸ Rules): a spelling some *live*
|
|
/// occurrence carries wins over one only trash ghosts carry (the container preference, stated
|
|
/// 2026-07-29), and among the candidates the canonical all-lowercase spelling wins where present,
|
|
/// else the lexicographically first. This is the loser. Skipped, preserved verbatim, never
|
|
/// rendered — **and never reminted**: it is a spelling artifact of the item that rendered, not a
|
|
/// copy, so there is nothing to heal.
|
|
///
|
|
/// Its home is here rather than in the defect stream precisely because of that: a warning says
|
|
/// "this was ignored, it is staying exactly where it is, there is nothing to do", which is the
|
|
/// whole of the tolerate tier's verdict on it. Both paths are root-relative.
|
|
case caseTwinIgnored(path: String, winner: String)
|
|
|
|
/// A fail-fast defect the **user chose to skip** on the decision surface (01-storage-format.md
|
|
/// § Malformed input, ruled 2026-07-31: "Skip is user-consented tolerance, loudly marked").
|
|
///
|
|
/// The item loads out of the board — subtree and all, the tolerated missing-`index.md` skip's
|
|
/// exact shape — and the file stays on disk untouched, tolerated-invisible like a stray. This is
|
|
/// the loud mark: "the opened board carries a warning-tone notice naming the skipped items", and
|
|
/// this warning is what that notice is written from.
|
|
///
|
|
/// It is a warning rather than a defect for the tolerate tier's own reason — nothing is pending,
|
|
/// the app has no business rewriting a file the user told it to leave alone — with one honest
|
|
/// difference from its neighbours here: the tolerance was *consented to* this open rather than
|
|
/// decided by a rule. Which is also why nothing about it persists: the skip set arrived with one
|
|
/// `load` call, "the next open of a still-broken board presents the surface again".
|
|
///
|
|
/// `path` is the **defect's** path — the offending `index.md`, root-relative — because that is
|
|
/// what the surface's row named and what its Reveal in Finder resolved against.
|
|
case userSkipped(path: String)
|
|
|
|
public var description: String {
|
|
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"
|
|
case let .laneLevelDeletedIgnored(path):
|
|
"\(path): lane-level 'deleted' key is inert, ignored — the lane loads live"
|
|
case let .caseTwinIgnored(path, winner):
|
|
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
|
|
case let .userSkipped(path):
|
|
"\(path): skipped at the user's request — the board loaded without it"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error
|
|
|
|
/// **Everything one walk refused**, as one value (01-storage-format.md § Malformed input, settled
|
|
/// 2026-07-31: "The loader collects every fail-fast defect in the walk rather than stopping at the
|
|
/// first … one aggregated surface presents them all").
|
|
///
|
|
/// The aggregate exists so no surface ever has to run a walk per defect: the decision surface groups
|
|
/// `defects` by class, the reload banner reads `primary` and counts the rest, and a re-check simply
|
|
/// produces a new one. `BoardLoadError` stays the per-defect record — the vocabulary every row,
|
|
/// banner and announcement is written against — and this type adds nothing to it but plurality.
|
|
///
|
|
/// **Never empty.** A failure with no defect is not a failure; `load` returns its `LoadResult` in
|
|
/// that case, which is what makes "throwing this means the walk produced no snapshot at all" still
|
|
/// true, defect by defect.
|
|
///
|
|
/// Ordered by the walk: the root first, then lanes in folder-name order with their cards inside
|
|
/// them, then `.trash/`.
|
|
public struct BoardLoadFailure: Error, Sendable, Equatable, CustomStringConvertible {
|
|
|
|
/// Every fail-fast defect the walk collected, in walk order. Non-empty by construction.
|
|
public let defects: [BoardLoadError]
|
|
|
|
public init(_ defects: [BoardLoadError]) {
|
|
precondition(!defects.isEmpty, "a BoardLoadFailure with no defect is not a failure")
|
|
self.defects = defects
|
|
}
|
|
|
|
/// The single-defect aggregate — the environmental failures, and every place that has exactly
|
|
/// one thing to say.
|
|
public init(_ defect: BoardLoadError) {
|
|
self.defects = [defect]
|
|
}
|
|
|
|
/// **The defect a one-line surface shows**: the first in walk order. The banner strip, the
|
|
/// welcome window's failure row and the template chooser's unloadable row each have room for one
|
|
/// sentence, and the first thing the walk met is the one that names the outermost problem —
|
|
/// a broken root before the lanes under it.
|
|
public var primary: BoardLoadError { defects[0] }
|
|
|
|
/// The primary defect's own sentence, with the rest counted rather than listed — a log line and
|
|
/// a diagnostic string, not a headline (`BannerCenter` owns the phrasing users read).
|
|
///
|
|
/// A single-defect failure reads *exactly* as its `BoardLoadError` always did, which is what
|
|
/// keeps every existing one-defect surface saying what it said before this type existed.
|
|
public var description: String {
|
|
defects.count == 1
|
|
? primary.description
|
|
: "\(primary.description) (and \(defects.count - 1) more)"
|
|
}
|
|
}
|
|
|
|
/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to
|
|
/// the board root where one exists) plus `reason` says exactly what's wrong.
|
|
///
|
|
/// **One defect, not the whole refusal.** A walk collects every one of these it meets and hands them
|
|
/// over together as a `BoardLoadFailure` (01-storage-format.md § Malformed input, settled
|
|
/// 2026-07-31); this stays the record a single decision-surface row, banner or announcement is
|
|
/// written against, and the unit the skip channel names by `path`.
|
|
public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
|
|
public let path: String
|
|
public let reason: Reason
|
|
|
|
public var description: String { "\(path): \(reason.description)" }
|
|
|
|
public enum Reason: Sendable, Equatable, CustomStringConvertible {
|
|
/// The board root itself has no `index.md` — unlike every level below it, this is not
|
|
/// skip-and-warn: there is no board without one.
|
|
case boardRootMissingIndex
|
|
/// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a
|
|
/// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
|
|
/// underlying error carries one.
|
|
case unparseableYAML(message: String, line: Int?)
|
|
/// **The board root's own missing `schema`** — the this-really-is-a-board gate (re-ruled
|
|
/// 2026-07-31). Below the root a missing `schema` reads as 1 instead, coerce-tier.
|
|
case missingSchema
|
|
case malformedSchema(raw: String)
|
|
/// `schema` is present, valid, and greater than this app's `supportedSchema`.
|
|
case schemaNewerThanApp(found: Int)
|
|
|
|
/// **Retired, and nothing throws these any more** (01-storage-format.md § Ordering and
|
|
/// § Malformed input, re-ruled 2026-07-31): below the board root a missing, null, non-numeric
|
|
/// or non-finite `order` reads as append-at-end — coerce-tier, logged, bytes preserved
|
|
/// (`IntegrityRules.resolvedOrder`) — and the board root never carried a rank to begin with.
|
|
///
|
|
/// They stay in the vocabulary rather than being deleted because this enum *is* the
|
|
/// load-failure surface every banner, announcement and decision-surface row is written
|
|
/// against, and a reason that can no longer occur costs those surfaces nothing while removing
|
|
/// one would rewrite them for a rule that changed underneath, not for a shape they render
|
|
/// differently.
|
|
case missingOrder
|
|
case malformedOrder(raw: String)
|
|
/// The board root exists but is a file, not a directory.
|
|
case notADirectory
|
|
/// The board root doesn't exist, or its contents couldn't be listed.
|
|
case unreadableRoot(message: String)
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .boardRootMissingIndex:
|
|
"board root is missing index.md"
|
|
case let .unparseableYAML(message, line):
|
|
if let line {
|
|
"unparseable YAML at line \(line): \(message)"
|
|
} else {
|
|
"unparseable YAML: \(message)"
|
|
}
|
|
case .missingSchema:
|
|
"missing required 'schema' field"
|
|
case let .malformedSchema(raw):
|
|
"malformed 'schema' field: \(raw)"
|
|
case let .schemaNewerThanApp(found):
|
|
"schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))"
|
|
case .missingOrder:
|
|
"missing required 'order' field"
|
|
case let .malformedOrder(raw):
|
|
"malformed 'order' field: \(raw)"
|
|
case .notADirectory:
|
|
"board root is not a directory"
|
|
case let .unreadableRoot(message):
|
|
"board root is unreadable: \(message)"
|
|
}
|
|
}
|
|
}
|
|
}
|