The loader collects every fail-fast defect and honors per-open skips
Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -184,7 +184,7 @@ struct BoardWindowHost: View {
|
||||
let recordID = appModel.boardRegistry.recordOpen(of: url)
|
||||
|
||||
let store: BoardStore
|
||||
do throws(BoardLoadError) {
|
||||
do throws(BoardLoadFailure) {
|
||||
store = try appModel.storeRegistry.acquire(url)
|
||||
} catch {
|
||||
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
||||
|
||||
@@ -575,7 +575,7 @@ struct CardWindowHost: View {
|
||||
}
|
||||
|
||||
let store: BoardStore
|
||||
do throws(BoardLoadError) {
|
||||
do throws(BoardLoadFailure) {
|
||||
store = try appModel.storeRegistry.acquire(ref.boardURL)
|
||||
} catch {
|
||||
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
|
||||
|
||||
@@ -142,12 +142,17 @@ enum TemplateEngine {
|
||||
/// The loader's error is handed back whole rather than reworded: the chooser's unloadable row
|
||||
/// shows "the loader's fail-fast specifics" (09 ▸ Why this format), and a second taxonomy of
|
||||
/// board problems is precisely what a files-first app must not grow.
|
||||
///
|
||||
/// **One defect of the walk's aggregate — the first** (`BoardLoadFailure.primary`). The chooser's
|
||||
/// unloadable row is one line about a folder the user is not being invited to repair: a template
|
||||
/// store is picked from, not opened, and the decision surface exists for the board being opened.
|
||||
/// Saying which thing is wrong first is the whole of what that row can act on.
|
||||
static func load(templateAt url: URL, origin: BoardTemplate.Origin) -> Result<BoardTemplate, BoardLoadError> {
|
||||
do {
|
||||
let result = try BoardLoader.load(boardRoot: url)
|
||||
return .success(BoardTemplate(url: url, origin: origin, model: result.model))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
return .failure(error.primary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,8 +244,10 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
/// vanished root, and the writability probe — at open and, symmetrically, on every reconciling
|
||||
/// reload thereafter.
|
||||
case readOnlyLock(ReadOnlyLockReason)
|
||||
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
||||
case reloadBreakage(BoardLoadError)
|
||||
/// A reload failed and the last good snapshot is still on screen. Condition, error tone. Carries
|
||||
/// the **whole** aggregate — one row either way, but its headline names the first defect and
|
||||
/// counts the rest rather than pretending the walk found only one.
|
||||
case reloadBreakage(BoardLoadFailure)
|
||||
/// A write that did not happen. Dismissable, error tone.
|
||||
case oneShot(OneShotBanner)
|
||||
/// A git operation that did not happen — an undo restore, a branch switch, and (pro-m2) a pull
|
||||
@@ -813,7 +815,7 @@ public final class BannerCenter {
|
||||
/// Finder drop that skipped folders already posts one (`postSkippedFolders`).
|
||||
public nonisolated static func rows(
|
||||
lock: ReadOnlyLockReason?,
|
||||
breakage: BoardLoadError?,
|
||||
breakage: BoardLoadFailure?,
|
||||
oneShots: [OneShotBanner],
|
||||
losses: [LossBanner],
|
||||
suspension: HistorySuspension?,
|
||||
@@ -1154,12 +1156,31 @@ public final class BannerCenter {
|
||||
///
|
||||
/// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
|
||||
/// `index.md` — is spelled as "This board" rather than shown as a lone dot.
|
||||
public nonisolated static func headline(for breakage: BoardLoadError) -> String {
|
||||
let reason = trimmed(breakage.reason.description)
|
||||
let subject = breakage.path == "." || breakage.path.isEmpty
|
||||
///
|
||||
/// **One defect is named, the rest are counted** (01-storage-format.md § Malformed input: the
|
||||
/// loader collects every fail-fast defect in a walk). A banner is one line and a list of paths
|
||||
/// is the first thing that would truncate, so the sentence stays the sentence it always was —
|
||||
/// the walk's first defect, said in full — with ", and N more" between the reason and the
|
||||
/// reassurance. The full list is not lost: it is the decision surface's to show on the next
|
||||
/// attended open, which is where a repair is actually made.
|
||||
///
|
||||
/// A single-defect failure reads **exactly** as it did before the aggregate existed.
|
||||
public nonisolated static func headline(for breakage: BoardLoadFailure) -> String {
|
||||
breakageHeadline(breakage.primary, others: breakage.defects.count - 1)
|
||||
}
|
||||
|
||||
/// One defect's own sentence — the same rule, for the surfaces that hold exactly one and know
|
||||
/// it: the template chooser's unloadable row, whose folder is picked from rather than opened.
|
||||
public nonisolated static func headline(for defect: BoardLoadError) -> String {
|
||||
breakageHeadline(defect, others: 0)
|
||||
}
|
||||
|
||||
private nonisolated static func breakageHeadline(_ defect: BoardLoadError, others: Int) -> String {
|
||||
let subject = defect.path == "." || defect.path.isEmpty
|
||||
? "This board isn't loading"
|
||||
: "'\(breakage.path)' isn't loading"
|
||||
return "\(subject): \(reason) — showing the last good view"
|
||||
: "'\(defect.path)' isn't loading"
|
||||
let more = others > 0 ? ", and \(others) more" : ""
|
||||
return "\(subject): \(trimmed(defect.reason.description))\(more) — showing the last good view"
|
||||
}
|
||||
|
||||
/// The skipped-folders line — 04-interactions.md's own example, "Folders can't be attached — 2
|
||||
|
||||
@@ -238,9 +238,11 @@ public enum BoardAnnouncer {
|
||||
public var lockAfter: ReadOnlyLockReason?
|
||||
|
||||
/// The reload-breakage condition before and after, same rule: a different file failing to
|
||||
/// load is a different sentence and is worth saying.
|
||||
public var breakageBefore: BoardLoadError?
|
||||
public var breakageAfter: BoardLoadError?
|
||||
/// load is a different sentence and is worth saying. **The whole aggregate**, not just its
|
||||
/// first defect — a second broken lane appearing under an already-broken one changes the
|
||||
/// sentence the strip is showing ("and 2 more"), so it is news by the same test.
|
||||
public var breakageBefore: BoardLoadFailure?
|
||||
public var breakageAfter: BoardLoadFailure?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
@@ -279,12 +279,13 @@ public final class BoardStore: HealHost {
|
||||
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
|
||||
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
||||
/// and what is wrong with it — which is the whole of what the banner needs to render
|
||||
/// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it
|
||||
/// stands until a reload succeeds, and it heals without ceremony when one does.
|
||||
public private(set) var reloadFailure: BoardLoadError?
|
||||
/// The standing read-side condition: the failure from the last reload that failed, `nil` when the
|
||||
/// board is healthy. `BoardLoadFailure` carries every fail-fast defect that walk found — each one
|
||||
/// the offending path and what is wrong with it — which is the whole of what the banner needs to
|
||||
/// render (02-architecture.md § Live-reload resilience; the banner shows `primary` and counts the
|
||||
/// rest). This is a *condition*, not a one-shot: it stands until a reload succeeds, and it heals
|
||||
/// without ceremony when one does.
|
||||
public private(set) var reloadFailure: BoardLoadFailure?
|
||||
|
||||
/// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the
|
||||
/// next successful reload clears both the banner and the lock".
|
||||
@@ -582,8 +583,9 @@ public final class BoardStore: HealHost {
|
||||
/// Opens a board: one synchronous tree walk, and **no fallback if it fails**.
|
||||
///
|
||||
/// Fail-fast is the *initial-load* contract (01-storage-format.md § Malformed input): there is
|
||||
/// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadError`
|
||||
/// instead of constructing a store that would have nothing to show. Every rule below — the
|
||||
/// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadFailure`
|
||||
/// — every fail-fast defect the walk found, aggregated — instead of constructing a store that
|
||||
/// would have nothing to show. Every rule below — the
|
||||
/// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because*
|
||||
/// this one succeeded.
|
||||
///
|
||||
@@ -596,7 +598,7 @@ public final class BoardStore: HealHost {
|
||||
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||
/// thereafter.
|
||||
public init(rootURL: URL) throws(BoardLoadError) {
|
||||
public init(rootURL: URL) throws(BoardLoadFailure) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
@@ -676,10 +678,10 @@ public final class BoardStore: HealHost {
|
||||
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
// `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error`
|
||||
// and the loader's typed error is lost on the way into `Result`.
|
||||
let outcome: Result<LoadResult, BoardLoadError>
|
||||
do throws(BoardLoadError) {
|
||||
// `do throws(BoardLoadFailure)`: without the annotation the `catch` widens to `any Error`
|
||||
// and the loader's typed failure is lost on the way into `Result`.
|
||||
let outcome: Result<LoadResult, BoardLoadFailure>
|
||||
do throws(BoardLoadFailure) {
|
||||
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
||||
} catch {
|
||||
outcome = .failure(error)
|
||||
@@ -690,7 +692,7 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
|
||||
/// Lands one walk's result and starts whatever it uncovered.
|
||||
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
|
||||
private func apply(_ outcome: Result<LoadResult, BoardLoadFailure>, generation: Int, origin: WatchOrigin) {
|
||||
reloadInFlight = false
|
||||
|
||||
// The stale-apply guard. Serialization means this should not trigger today, but "only the
|
||||
@@ -704,7 +706,7 @@ public final class BoardStore: HealHost {
|
||||
resumeQuiescenceWaitersIfQuiet()
|
||||
}
|
||||
|
||||
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
|
||||
private func land(_ outcome: Result<LoadResult, BoardLoadFailure>, generation: Int, origin: WatchOrigin) {
|
||||
// Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale
|
||||
// operation gets exactly one reload to prove itself, and a second failure after it is
|
||||
// ordinary per-file breakage again.
|
||||
|
||||
@@ -119,7 +119,8 @@ public final class BoardStoreRegistry {
|
||||
|
||||
/// The store for `rootURL`, opening the board if this is the first window to ask for it.
|
||||
///
|
||||
/// **First acquire**: loads the board (fail-fast — the `BoardLoadError` is rethrown untouched,
|
||||
/// **First acquire**: loads the board (fail-fast — the `BoardLoadFailure` is rethrown untouched,
|
||||
/// every collected defect included, because the decision surface is the caller's to host —
|
||||
/// because there is nothing to render and nothing to fall back on), then creates and starts the
|
||||
/// watcher and ties the two together in both directions — watcher events into
|
||||
/// `BoardStore.handleWatcherEvent(_:)`, the store's write brackets out to
|
||||
@@ -136,7 +137,7 @@ public final class BoardStoreRegistry {
|
||||
///
|
||||
/// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed
|
||||
/// to open is not open.
|
||||
public func acquire(_ rootURL: URL) throws(BoardLoadError) -> BoardStore {
|
||||
public func acquire(_ rootURL: URL) throws(BoardLoadFailure) -> BoardStore {
|
||||
if let identity = FileIdentity(of: rootURL), var entry = entries[identity] {
|
||||
entry.referenceCount += 1
|
||||
entries[identity] = entry
|
||||
@@ -167,7 +168,8 @@ public final class BoardStoreRegistry {
|
||||
// out. The loader's own vocabulary says it; no new error path is invented for a case that
|
||||
// means exactly what `unreadableRoot` already means.
|
||||
guard let identity = FileIdentity(of: rootURL) else {
|
||||
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity"))
|
||||
throw BoardLoadFailure(
|
||||
BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity")))
|
||||
}
|
||||
|
||||
// Both directions of the wiring capture weakly, and the registry's entry is what keeps the
|
||||
|
||||
@@ -150,6 +150,18 @@ public enum BoardLoader: Sendable {
|
||||
/// 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
|
||||
@@ -183,21 +195,53 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
// 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.
|
||||
public static func load(
|
||||
boardRoot: URL,
|
||||
skipping: Set<String> = [],
|
||||
historyRanker: IdentityHistoryRanker? = nil
|
||||
) throws(BoardLoadError) -> LoadResult {
|
||||
try checkIsReadableDirectory(boardRoot)
|
||||
|
||||
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: boardIndexURL.path) else {
|
||||
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
|
||||
) 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)
|
||||
}
|
||||
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
|
||||
// **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.
|
||||
let boardSchema = try validatedRootSchema(in: boardDocument, path: indexFileName)
|
||||
|
||||
var warnings: [LoadWarning] = []
|
||||
func warn(_ warning: LoadWarning) {
|
||||
@@ -205,6 +249,47 @@ public enum BoardLoader: Sendable {
|
||||
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] = []
|
||||
|
||||
/// 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 document = try readDocument(at: boardIndexURL, path: indexFileName)
|
||||
// **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: document, path: indexFileName)
|
||||
boardDocument = document
|
||||
} 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
|
||||
@@ -253,16 +338,21 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
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
|
||||
@@ -274,7 +364,11 @@ public enum BoardLoader: Sendable {
|
||||
// the far side of that decision, because a `Lane` carrying a withheld card would be exactly
|
||||
// the snapshot the invariant forbids.
|
||||
var walkedLanes: [WalkedLane] = []
|
||||
for laneURL in try directoryCandidates(in: boardRoot) {
|
||||
// `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
|
||||
@@ -290,11 +384,23 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
let lanePath = laneName + "/" + indexFileName
|
||||
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
|
||||
// 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.
|
||||
let laneSchema = try resolvedSchema(in: laneDocument, path: lanePath)
|
||||
let laneDocument: FrontmatterDocument
|
||||
let laneSchema: (schema: Int, coerced: CoercedField?)
|
||||
// **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) {
|
||||
laneDocument = try readDocument(
|
||||
at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
|
||||
// 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
|
||||
}
|
||||
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
|
||||
noteCoercions(
|
||||
in: laneDocument,
|
||||
@@ -303,7 +409,7 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
|
||||
var walkedCards: [WalkedCard] = []
|
||||
for cardURL in try directoryCandidates(in: laneURL) {
|
||||
for cardURL in (try? directoryCandidates(in: laneURL)) ?? [] {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = laneName + "/" + cardName
|
||||
guard isUUIDShaped(cardName) else {
|
||||
@@ -315,7 +421,13 @@ public enum BoardLoader: Sendable {
|
||||
continue
|
||||
}
|
||||
|
||||
let card = try parseCard(at: cardURL, path: cardRelPath)
|
||||
let card: WalkedCard
|
||||
do throws(BoardLoadError) {
|
||||
card = try parseCard(at: cardURL, path: cardRelPath)
|
||||
} catch {
|
||||
record(error)
|
||||
continue
|
||||
}
|
||||
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)
|
||||
|
||||
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
@@ -425,8 +537,19 @@ public enum BoardLoader: Sendable {
|
||||
// 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 = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
|
||||
let schema = try resolvedSchema(in: document, path: entryPath)
|
||||
let document: FrontmatterDocument
|
||||
let schema: (schema: Int, coerced: CoercedField?)
|
||||
// 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) {
|
||||
document = try readDocument(
|
||||
at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
|
||||
schema = try resolvedSchema(in: document, path: entryPath)
|
||||
} catch {
|
||||
record(error)
|
||||
continue
|
||||
}
|
||||
let order = IntegrityRules.resolvedOrder(in: document)
|
||||
noteCoercions(
|
||||
in: document,
|
||||
@@ -476,6 +599,12 @@ public enum BoardLoader: Sendable {
|
||||
))
|
||||
}
|
||||
|
||||
// **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
|
||||
@@ -569,6 +698,14 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
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,
|
||||
@@ -1324,6 +1461,24 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
||||
/// 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):
|
||||
@@ -1336,15 +1491,70 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
||||
"\(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. No partial
|
||||
/// loads: throwing this means `BoardLoader.load` produced nothing at all.
|
||||
/// 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
|
||||
|
||||
@@ -315,7 +315,7 @@ private func previewError(
|
||||
rows: [
|
||||
.inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
|
||||
.readOnlyLock(.bracketedReloadFailed),
|
||||
.reloadBreakage(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
|
||||
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "todo/index.md", reason: .missingOrder))),
|
||||
.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login")))),
|
||||
.oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
|
||||
.oneShot(OneShotBanner(error: previewError(.renumberChildren))),
|
||||
|
||||
Reference in New Issue
Block a user