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:
+8
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: card
|
||||||
|
order: 1024
|
||||||
|
title: Intact Card
|
||||||
|
---
|
||||||
|
Nothing wrong with this one. It is here so the broken sibling below is a *card* defect rather
|
||||||
|
than the only thing in its lane.
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
schema: 2
|
||||||
|
kind: card
|
||||||
|
order: 2048
|
||||||
|
title: From A Newer Lanework
|
||||||
|
---
|
||||||
|
Defect #2: a schema newer than this app, below the root — the one `schema` rule the optional-key
|
||||||
|
ruling left alone. Unfixable, so the surface offers only Skip.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: lane
|
||||||
|
order: 1024
|
||||||
|
title: Intact Lane
|
||||||
|
---
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
schema: 3
|
||||||
|
kind: card
|
||||||
|
order: 1024
|
||||||
|
title: Hidden Behind The Broken Lane
|
||||||
|
---
|
||||||
|
Broken too, and deliberately *not* in the aggregate: its lane was skipped before this folder was
|
||||||
|
ever listed. Fix the lane and the next walk reports this one.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: lane
|
||||||
|
order: 2048
|
||||||
|
title: A Lane That Will Not Parse
|
||||||
|
labels: [red, green
|
||||||
|
---
|
||||||
|
Defect #3: unparseable YAML — an unterminated flow sequence, the same shape
|
||||||
|
`unparseable-yaml.kanban` uses one level up. The card below is never enumerated: a broken lane
|
||||||
|
takes its subtree with it, and repair-then-re-check is what reveals what it was hiding.
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: card
|
||||||
|
order: 1024
|
||||||
|
title: Last Card
|
||||||
|
---
|
||||||
|
The walk reaches here, which is the point: a defect in an earlier lane does not end the walk.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: lane
|
||||||
|
order: 3072
|
||||||
|
title: Also Intact
|
||||||
|
---
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Many Defects
|
||||||
|
created: 2026-07-31T09:00:00Z
|
||||||
|
---
|
||||||
|
The root's own `schema` is missing — the this-really-is-a-board gate, and defect #1.
|
||||||
|
|
||||||
|
The walk does not stop here: lanes are enumerated by folder shape, so everything below is still
|
||||||
|
read and reported in the same aggregate.
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: card
|
||||||
|
order: 1024
|
||||||
|
title: Intact Card
|
||||||
|
---
|
||||||
|
Stays on the board whatever is skipped — a skip omits the item it names, never its siblings.
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
schema: 2
|
||||||
|
kind: card
|
||||||
|
order: 2048
|
||||||
|
title: From A Newer Lanework
|
||||||
|
---
|
||||||
|
Unfixable, so Skip is the only choice the surface offers for it. Skipped, the card leaves the
|
||||||
|
board and this file stays on disk untouched.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: lane
|
||||||
|
order: 1024
|
||||||
|
title: Intact Lane
|
||||||
|
---
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: card
|
||||||
|
order: 1024
|
||||||
|
title: Perfectly Fine, Behind A Broken Lane
|
||||||
|
---
|
||||||
|
Nothing at all is wrong with this card. It is never in the snapshot regardless: its lane is either
|
||||||
|
a defect or a skip, and both take the subtree.
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: lane
|
||||||
|
order: 2048
|
||||||
|
title: A Lane That Will Not Parse
|
||||||
|
labels: [red, green
|
||||||
|
---
|
||||||
|
Unparseable YAML. Skipping it takes the whole lane out of the board — the intact card below
|
||||||
|
included, which is the honest cost of the tolerance and why the notice names what left.
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: card
|
||||||
|
order: 1024
|
||||||
|
title: Last Card
|
||||||
|
---
|
||||||
|
Proof the walk kept going past the broken lane.
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
kind: lane
|
||||||
|
order: 3072
|
||||||
|
title: Also Intact
|
||||||
|
---
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Skippable Defects
|
||||||
|
created: 2026-07-31T09:00:00Z
|
||||||
|
---
|
||||||
|
Every defect on this board is **below the root**, which is what makes it the skip channel's golden
|
||||||
|
case: the root is never skippable, so a board whose only defects are skippable has to have an
|
||||||
|
intact root.
|
||||||
|
|
||||||
|
Skip both and it opens — without the broken lane, without the newer-schema card, and without the
|
||||||
|
perfectly good card that lives under the broken lane.
|
||||||
+3
-1
@@ -26,7 +26,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never
|
|||||||
|
|
||||||
## Malformed/ — one board per fail-fast case
|
## Malformed/ — one board per fail-fast case
|
||||||
|
|
||||||
Each board is minimal: one broken thing.
|
Each board is minimal: one broken thing. The two multi-defect boards at the bottom are the deliberate exceptions — they exist precisely because the loader collects rather than stops (01-storage-format.md § Malformed input, settled 2026-07-31), which is a claim no one-broken-thing board can make.
|
||||||
|
|
||||||
| Board | Case |
|
| Board | Case |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -34,5 +34,7 @@ Each board is minimal: one broken thing.
|
|||||||
| `missing-schema.kanban` | Board root `index.md` has no `schema` key — the **root only**; below it a missing `schema` reads as 1 (`Valid/optional-keys.kanban`). |
|
| `missing-schema.kanban` | Board root `index.md` has no `schema` key — the **root only**; below it a missing `schema` reads as 1 (`Valid/optional-keys.kanban`). |
|
||||||
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
|
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
|
||||||
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
|
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
|
||||||
|
| `many-defects.kanban` | **The collect-all board.** A root with no `schema`, a card with `schema: 2`, and a lane whose frontmatter will not parse — three fail-fast classes on one board, reported as one `BoardLoadFailure` in walk order (root, then lanes by folder name with their cards inside them). The broken lane also holds a broken card, which is deliberately **not** in the aggregate: a broken lane takes its subtree with it, and the repair's re-check is what reveals what it was hiding. Two further lanes load fine, which is what proves the walk kept going. |
|
||||||
|
| `skippable-defects.kanban` | **The skip channel's board.** `many-defects.kanban` minus the root defect — the root is never skippable, so a board whose defects are all skippable must have an intact root. Skip both (the newer-schema card and the unparseable lane) and it opens: without them, without the broken lane's own perfectly valid card (the subtree goes too), and with a `LoadWarning.userSkipped` per skip as the loud mark the opened board's notice is written from. |
|
||||||
|
|
||||||
The four `order` boards that used to live here — `missing-order-lane`, `missing-order-card`, `explicit-null-order`, `non-numeric-order` — were retired on 2026-07-31, when `order` became optional below the board root. Their shapes all live on in `Valid/optional-keys.kanban` as coercion cases.
|
The four `order` boards that used to live here — `missing-order-lane`, `missing-order-card`, `explicit-null-order`, `non-numeric-order` — were retired on 2026-07-31, when `order` became optional below the board root. Their shapes all live on in `Valid/optional-keys.kanban` as coercion cases.
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ struct BoardWindowHost: View {
|
|||||||
let recordID = appModel.boardRegistry.recordOpen(of: url)
|
let recordID = appModel.boardRegistry.recordOpen(of: url)
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
store = try appModel.storeRegistry.acquire(url)
|
store = try appModel.storeRegistry.acquire(url)
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ struct CardWindowHost: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
store = try appModel.storeRegistry.acquire(ref.boardURL)
|
store = try appModel.storeRegistry.acquire(ref.boardURL)
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
|
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
|
/// 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
|
/// 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.
|
/// 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> {
|
static func load(templateAt url: URL, origin: BoardTemplate.Origin) -> Result<BoardTemplate, BoardLoadError> {
|
||||||
do {
|
do {
|
||||||
let result = try BoardLoader.load(boardRoot: url)
|
let result = try BoardLoader.load(boardRoot: url)
|
||||||
return .success(BoardTemplate(url: url, origin: origin, model: result.model))
|
return .success(BoardTemplate(url: url, origin: origin, model: result.model))
|
||||||
} catch {
|
} 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
|
/// vanished root, and the writability probe — at open and, symmetrically, on every reconciling
|
||||||
/// reload thereafter.
|
/// reload thereafter.
|
||||||
case readOnlyLock(ReadOnlyLockReason)
|
case readOnlyLock(ReadOnlyLockReason)
|
||||||
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
/// A reload failed and the last good snapshot is still on screen. Condition, error tone. Carries
|
||||||
case reloadBreakage(BoardLoadError)
|
/// 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.
|
/// A write that did not happen. Dismissable, error tone.
|
||||||
case oneShot(OneShotBanner)
|
case oneShot(OneShotBanner)
|
||||||
/// A git operation that did not happen — an undo restore, a branch switch, and (pro-m2) a pull
|
/// 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`).
|
/// Finder drop that skipped folders already posts one (`postSkippedFolders`).
|
||||||
public nonisolated static func rows(
|
public nonisolated static func rows(
|
||||||
lock: ReadOnlyLockReason?,
|
lock: ReadOnlyLockReason?,
|
||||||
breakage: BoardLoadError?,
|
breakage: BoardLoadFailure?,
|
||||||
oneShots: [OneShotBanner],
|
oneShots: [OneShotBanner],
|
||||||
losses: [LossBanner],
|
losses: [LossBanner],
|
||||||
suspension: HistorySuspension?,
|
suspension: HistorySuspension?,
|
||||||
@@ -1154,12 +1156,31 @@ public final class BannerCenter {
|
|||||||
///
|
///
|
||||||
/// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
|
/// 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.
|
/// `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)
|
/// **One defect is named, the rest are counted** (01-storage-format.md § Malformed input: the
|
||||||
let subject = breakage.path == "." || breakage.path.isEmpty
|
/// 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"
|
? "This board isn't loading"
|
||||||
: "'\(breakage.path)' isn't loading"
|
: "'\(defect.path)' isn't loading"
|
||||||
return "\(subject): \(reason) — showing the last good view"
|
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
|
/// 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?
|
public var lockAfter: ReadOnlyLockReason?
|
||||||
|
|
||||||
/// The reload-breakage condition before and after, same rule: a different file failing to
|
/// The reload-breakage condition before and after, same rule: a different file failing to
|
||||||
/// load is a different sentence and is worth saying.
|
/// load is a different sentence and is worth saying. **The whole aggregate**, not just its
|
||||||
public var breakageBefore: BoardLoadError?
|
/// first defect — a second broken lane appearing under an already-broken one changes the
|
||||||
public var breakageAfter: BoardLoadError?
|
/// 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() {}
|
public init() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,12 +279,13 @@ public final class BoardStore: HealHost {
|
|||||||
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
|
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
|
/// The standing read-side condition: the failure from the last reload that failed, `nil` when the
|
||||||
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
/// board is healthy. `BoardLoadFailure` carries every fail-fast defect that walk found — each one
|
||||||
/// and what is wrong with it — which is the whole of what the banner needs to render
|
/// the offending path and what is wrong with it — which is the whole of what the banner needs to
|
||||||
/// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it
|
/// render (02-architecture.md § Live-reload resilience; the banner shows `primary` and counts the
|
||||||
/// stands until a reload succeeds, and it heals without ceremony when one does.
|
/// rest). This is a *condition*, not a one-shot: it stands until a reload succeeds, and it heals
|
||||||
public private(set) var reloadFailure: BoardLoadError?
|
/// 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
|
/// 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".
|
/// 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**.
|
/// 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
|
/// 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`
|
/// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadFailure`
|
||||||
/// instead of constructing a store that would have nothing to show. Every rule below — the
|
/// — 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*
|
/// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because*
|
||||||
/// this one succeeded.
|
/// 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
|
/// 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
|
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||||
/// thereafter.
|
/// thereafter.
|
||||||
public init(rootURL: URL) throws(BoardLoadError) {
|
public init(rootURL: URL) throws(BoardLoadFailure) {
|
||||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||||
self.rootURL = rootURL
|
self.rootURL = rootURL
|
||||||
self.snapshot = result.model
|
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))")
|
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
// `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error`
|
// `do throws(BoardLoadFailure)`: without the annotation the `catch` widens to `any Error`
|
||||||
// and the loader's typed error is lost on the way into `Result`.
|
// and the loader's typed failure is lost on the way into `Result`.
|
||||||
let outcome: Result<LoadResult, BoardLoadError>
|
let outcome: Result<LoadResult, BoardLoadFailure>
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
||||||
} catch {
|
} catch {
|
||||||
outcome = .failure(error)
|
outcome = .failure(error)
|
||||||
@@ -690,7 +692,7 @@ public final class BoardStore: HealHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lands one walk's result and starts whatever it uncovered.
|
/// 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
|
reloadInFlight = false
|
||||||
|
|
||||||
// The stale-apply guard. Serialization means this should not trigger today, but "only the
|
// The stale-apply guard. Serialization means this should not trigger today, but "only the
|
||||||
@@ -704,7 +706,7 @@ public final class BoardStore: HealHost {
|
|||||||
resumeQuiescenceWaitersIfQuiet()
|
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
|
// 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
|
// operation gets exactly one reload to prove itself, and a second failure after it is
|
||||||
// ordinary per-file breakage again.
|
// 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.
|
/// 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
|
/// 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
|
/// watcher and ties the two together in both directions — watcher events into
|
||||||
/// `BoardStore.handleWatcherEvent(_:)`, the store's write brackets out to
|
/// `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
|
/// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed
|
||||||
/// to open is not open.
|
/// 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] {
|
if let identity = FileIdentity(of: rootURL), var entry = entries[identity] {
|
||||||
entry.referenceCount += 1
|
entry.referenceCount += 1
|
||||||
entries[identity] = entry
|
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
|
// out. The loader's own vocabulary says it; no new error path is invented for a case that
|
||||||
// means exactly what `unreadableRoot` already means.
|
// means exactly what `unreadableRoot` already means.
|
||||||
guard let identity = FileIdentity(of: rootURL) else {
|
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
|
// 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.
|
/// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names.
|
||||||
static let gitignoreFileName = IntegrityRules.gitignoreFileName
|
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")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
||||||
|
|
||||||
// MARK: - The noise gate
|
// MARK: - The noise gate
|
||||||
@@ -183,21 +195,53 @@ public enum BoardLoader: Sendable {
|
|||||||
|
|
||||||
// MARK: - Entry point
|
// 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(
|
public static func load(
|
||||||
boardRoot: URL,
|
boardRoot: URL,
|
||||||
|
skipping: Set<String> = [],
|
||||||
historyRanker: IdentityHistoryRanker? = nil
|
historyRanker: IdentityHistoryRanker? = nil
|
||||||
) throws(BoardLoadError) -> LoadResult {
|
) throws(BoardLoadFailure) -> LoadResult {
|
||||||
|
// Environmental, so immediate: a root that cannot be listed has no walk to collect from.
|
||||||
|
do throws(BoardLoadError) {
|
||||||
try checkIsReadableDirectory(boardRoot)
|
try checkIsReadableDirectory(boardRoot)
|
||||||
|
} catch {
|
||||||
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
|
throw BoardLoadFailure(error)
|
||||||
guard FileManager.default.fileExists(atPath: boardIndexURL.path) else {
|
|
||||||
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
|
|
||||||
}
|
}
|
||||||
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] = []
|
var warnings: [LoadWarning] = []
|
||||||
func warn(_ warning: LoadWarning) {
|
func warn(_ warning: LoadWarning) {
|
||||||
@@ -205,6 +249,47 @@ public enum BoardLoader: Sendable {
|
|||||||
logger.warning("\(warning.description, privacy: .public)")
|
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
|
// **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
|
// 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
|
// stray-*tolerance* vocabulary — information, not work. The two ad-hoc repair channels this
|
||||||
@@ -253,6 +338,10 @@ public enum BoardLoader: Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
||||||
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
|
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
|
||||||
@@ -263,6 +352,7 @@ public enum BoardLoader: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 ▸
|
// **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
|
// 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 far side of that decision, because a `Lane` carrying a withheld card would be exactly
|
||||||
// the snapshot the invariant forbids.
|
// the snapshot the invariant forbids.
|
||||||
var walkedLanes: [WalkedLane] = []
|
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
|
let laneName = laneURL.lastPathComponent
|
||||||
// The app-claimed board-root names are not strays and must not warn as such. Hidden
|
// 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
|
// 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 lanePath = laneName + "/" + indexFileName
|
||||||
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
|
let laneDocument: FrontmatterDocument
|
||||||
// Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema` reads
|
let laneSchema: (schema: Int, coerced: CoercedField?)
|
||||||
// as 1, a missing or unusable `order` as append-at-end. Both readings are coerce-tier —
|
// **A broken lane takes its subtree with it** (the collect-and-skip rule above): the
|
||||||
// recorded here, logged, and acted on by nothing until the file's next Writer touch.
|
// defect is recorded, the lane's cards are not enumerated, and the repair's re-check is
|
||||||
let laneSchema = try resolvedSchema(in: laneDocument, path: lanePath)
|
// 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)
|
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
|
||||||
noteCoercions(
|
noteCoercions(
|
||||||
in: laneDocument,
|
in: laneDocument,
|
||||||
@@ -303,7 +409,7 @@ public enum BoardLoader: Sendable {
|
|||||||
)
|
)
|
||||||
|
|
||||||
var walkedCards: [WalkedCard] = []
|
var walkedCards: [WalkedCard] = []
|
||||||
for cardURL in try directoryCandidates(in: laneURL) {
|
for cardURL in (try? directoryCandidates(in: laneURL)) ?? [] {
|
||||||
let cardName = cardURL.lastPathComponent
|
let cardName = cardURL.lastPathComponent
|
||||||
let cardRelPath = laneName + "/" + cardName
|
let cardRelPath = laneName + "/" + cardName
|
||||||
guard isUUIDShaped(cardName) else {
|
guard isUUIDShaped(cardName) else {
|
||||||
@@ -315,7 +421,13 @@ public enum BoardLoader: Sendable {
|
|||||||
continue
|
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)
|
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)
|
||||||
|
|
||||||
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
|
// **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
|
// 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.
|
// schema newer than this app fails fast whichever kind the entry turns out to be.
|
||||||
let entryPath = entryRelPath + "/" + indexFileName
|
let entryPath = entryRelPath + "/" + indexFileName
|
||||||
let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
|
let document: FrontmatterDocument
|
||||||
let schema = try resolvedSchema(in: document, path: entryPath)
|
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)
|
let order = IntegrityRules.resolvedOrder(in: document)
|
||||||
noteCoercions(
|
noteCoercions(
|
||||||
in: document,
|
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
|
// 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
|
// 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
|
// 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))
|
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(
|
let model = BoardModel(
|
||||||
rootURL: boardRoot,
|
rootURL: boardRoot,
|
||||||
schema: boardSchema,
|
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.
|
/// whole of the tolerate tier's verdict on it. Both paths are root-relative.
|
||||||
case caseTwinIgnored(path: String, winner: String)
|
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 {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .missingIndex(path):
|
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"
|
"\(path): lane-level 'deleted' key is inert, ignored — the lane loads live"
|
||||||
case let .caseTwinIgnored(path, winner):
|
case let .caseTwinIgnored(path, winner):
|
||||||
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
|
"\(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
|
// 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
|
/// 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
|
/// the board root where one exists) plus `reason` says exactly what's wrong.
|
||||||
/// loads: throwing this means `BoardLoader.load` produced nothing at all.
|
///
|
||||||
|
/// **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 struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
|
||||||
public let path: String
|
public let path: String
|
||||||
public let reason: Reason
|
public let reason: Reason
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ private func previewError(
|
|||||||
rows: [
|
rows: [
|
||||||
.inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
|
.inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
|
||||||
.readOnlyLock(.bracketedReloadFailed),
|
.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(.move(title: "Fix login")))),
|
||||||
.oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
|
.oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
|
||||||
.oneShot(OneShotBanner(error: previewError(.renumberChildren))),
|
.oneShot(OneShotBanner(error: previewError(.renumberChildren))),
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ struct AppModelTests {
|
|||||||
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
|
|
||||||
var failureMessage: String?
|
var failureMessage: String?
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
_ = try model.storeRegistry.acquire(fixture.root)
|
_ = try model.storeRegistry.acquire(fixture.root)
|
||||||
Issue.record("expected the load to fail fail-fast")
|
Issue.record("expected the load to fail fail-fast")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -217,7 +217,7 @@ struct AppModelTests {
|
|||||||
|
|
||||||
let ref = BoardWindowRef(url: fixture.root)
|
let ref = BoardWindowRef(url: fixture.root)
|
||||||
let firstAttempt = model.boardRegistry.recordOpen(of: fixture.root)
|
let firstAttempt = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
_ = try model.storeRegistry.acquire(fixture.root)
|
_ = try model.storeRegistry.acquire(fixture.root)
|
||||||
Issue.record("expected the load to fail fail-fast")
|
Issue.record("expected the load to fail fail-fast")
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ struct BannerCenterOrderingTests {
|
|||||||
|
|
||||||
let rows = BannerCenter.rows(
|
let rows = BannerCenter.rows(
|
||||||
lock: .vanishedRoot,
|
lock: .vanishedRoot,
|
||||||
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
breakage: BoardLoadFailure(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
|
||||||
oneShots: [attachment, move],
|
oneShots: [attachment, move],
|
||||||
losses: [loss],
|
losses: [loss],
|
||||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||||
@@ -322,7 +322,7 @@ struct BannerCenterLifecycleTests {
|
|||||||
// heal", 02 § The banner surface), and this is where it is stated as a test.
|
// heal", 02 § The banner surface), and this is where it is stated as a test.
|
||||||
let rows = BannerCenter.rows(
|
let rows = BannerCenter.rows(
|
||||||
lock: .bracketedReloadFailed,
|
lock: .bracketedReloadFailed,
|
||||||
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
breakage: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)),
|
||||||
oneShots: center.oneShots,
|
oneShots: center.oneShots,
|
||||||
losses: [],
|
losses: [],
|
||||||
suspension: HistorySuspension(reason: "disk full"),
|
suspension: HistorySuspension(reason: "disk full"),
|
||||||
@@ -603,7 +603,7 @@ struct BannerRowControlsTests {
|
|||||||
func conditionRowsCarryNoControls() {
|
func conditionRowsCarryNoControls() {
|
||||||
let rows: [BannerRow] = [
|
let rows: [BannerRow] = [
|
||||||
.readOnlyLock(.vanishedRoot),
|
.readOnlyLock(.vanishedRoot),
|
||||||
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
|
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
|
||||||
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -618,7 +618,7 @@ struct BannerRowControlsTests {
|
|||||||
func inventoryAgreesWithDismissID() {
|
func inventoryAgreesWithDismissID() {
|
||||||
let rows: [BannerRow] = [
|
let rows: [BannerRow] = [
|
||||||
.readOnlyLock(.vanishedRoot),
|
.readOnlyLock(.vanishedRoot),
|
||||||
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
|
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
|
||||||
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
|
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
|
||||||
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
|
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
|
||||||
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
||||||
@@ -790,7 +790,9 @@ struct BannerCenterPhrasingTests {
|
|||||||
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
|
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
|
||||||
func breakageHeadlineNamesThePath() {
|
func breakageHeadlineNamesThePath() {
|
||||||
let headline = BannerCenter.headline(
|
let headline = BannerCenter.headline(
|
||||||
for: BoardLoadError(path: "todo/fix-login/index.md", reason: .unparseableYAML(message: "unexpected end", line: 4))
|
for: BoardLoadFailure(BoardLoadError(
|
||||||
|
path: "todo/fix-login/index.md",
|
||||||
|
reason: .unparseableYAML(message: "unexpected end", line: 4)))
|
||||||
)
|
)
|
||||||
#expect(headline.contains("'todo/fix-login/index.md'"))
|
#expect(headline.contains("'todo/fix-login/index.md'"))
|
||||||
#expect(headline.contains("line 4"))
|
#expect(headline.contains("line 4"))
|
||||||
@@ -798,11 +800,39 @@ struct BannerCenterPhrasingTests {
|
|||||||
|
|
||||||
// The board's own index.md reports as "." — a lone dot in the product's voice would be a
|
// The board's own index.md reports as "." — a lone dot in the product's voice would be a
|
||||||
// bug report, not a sentence.
|
// bug report, not a sentence.
|
||||||
let rootHeadline = BannerCenter.headline(for: BoardLoadError(path: ".", reason: .boardRootMissingIndex))
|
let rootHeadline = BannerCenter.headline(
|
||||||
|
for: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)))
|
||||||
#expect(!rootHeadline.contains("'.'"))
|
#expect(!rootHeadline.contains("'.'"))
|
||||||
#expect(rootHeadline.hasPrefix("This board isn't loading"))
|
#expect(rootHeadline.hasPrefix("This board isn't loading"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **One defect named, the rest counted** (01-storage-format.md § Malformed input — the loader
|
||||||
|
/// collects every fail-fast defect in a walk). A banner is one line, so the sentence stays the
|
||||||
|
/// sentence it always was and the remainder rides as a count; the full list is the decision
|
||||||
|
/// surface's to show on the next attended open.
|
||||||
|
///
|
||||||
|
/// Both spellings are pinned, because the single-defect one is what every existing surface reads
|
||||||
|
/// and it must not have drifted when the aggregate arrived.
|
||||||
|
@Test("A multi-defect breakage names the first and counts the rest")
|
||||||
|
func breakageHeadlineCountsTheRest() {
|
||||||
|
let first = BoardLoadError(path: "index.md", reason: .missingSchema)
|
||||||
|
let second = BoardLoadError(path: "todo/index.md", reason: .schemaNewerThanApp(found: 2))
|
||||||
|
let third = BoardLoadError(path: "done/index.md", reason: .malformedSchema(raw: "one"))
|
||||||
|
|
||||||
|
#expect(BannerCenter.headline(for: BoardLoadFailure([first]))
|
||||||
|
== "'index.md' isn't loading: missing required 'schema' field — showing the last good view")
|
||||||
|
|
||||||
|
#expect(BannerCenter.headline(for: BoardLoadFailure([first, second]))
|
||||||
|
== "'index.md' isn't loading: missing required 'schema' field, and 1 more — showing the last good view")
|
||||||
|
|
||||||
|
#expect(BannerCenter.headline(for: BoardLoadFailure([first, second, third]))
|
||||||
|
== "'index.md' isn't loading: missing required 'schema' field, and 2 more — showing the last good view")
|
||||||
|
|
||||||
|
// The row that carries it says the same thing — the headline is not re-derived anywhere.
|
||||||
|
#expect(BannerRow.reloadBreakage(BoardLoadFailure([first, second])).headline
|
||||||
|
== BannerCenter.headline(for: BoardLoadFailure([first, second])))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("The suspended-history line names the consequence, then the diagnosis")
|
@Test("The suspended-history line names the consequence, then the diagnosis")
|
||||||
func suspensionHeadlineNamesTheConsequence() {
|
func suspensionHeadlineNamesTheConsequence() {
|
||||||
#expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
|
#expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
|
||||||
|
|||||||
@@ -316,8 +316,8 @@ struct BoardAnnouncerSpeechTests {
|
|||||||
return diff
|
return diff
|
||||||
}
|
}
|
||||||
|
|
||||||
private func breakage() -> BoardLoadError {
|
private func breakage() -> BoardLoadFailure {
|
||||||
BoardLoadError(path: "Todo/index.md", reason: .missingOrder)
|
BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Provenance
|
// MARK: Provenance
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ private func uuidFolderName() -> String {
|
|||||||
UUID().uuidString.lowercased()
|
UUID().uuidString.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One defect, and **only** one: the whole aggregate is asserted rather than its first entry, so a
|
||||||
|
/// board authored to break in one place cannot quietly start reporting two.
|
||||||
private func expectFailure(
|
private func expectFailure(
|
||||||
_ expectedReason: BoardLoadError.Reason,
|
_ expectedReason: BoardLoadError.Reason,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -65,11 +67,10 @@ private func expectFailure(
|
|||||||
do {
|
do {
|
||||||
try operation()
|
try operation()
|
||||||
Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded")
|
Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded")
|
||||||
} catch let error as BoardLoadError {
|
} catch let failure as BoardLoadFailure {
|
||||||
#expect(error.path == path)
|
#expect(failure.defects == [BoardLoadError(path: path, reason: expectedReason)])
|
||||||
#expect(error.reason == expectedReason)
|
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("expected a BoardLoadError, got \(error)")
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,13 +659,16 @@ struct BoardLoaderFailFastTests {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try BoardLoader.load(boardRoot: missing)
|
_ = try BoardLoader.load(boardRoot: missing)
|
||||||
Issue.record("expected a BoardLoadError but load succeeded")
|
Issue.record("expected a BoardLoadFailure but load succeeded")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(error.path == ".")
|
// Environmental, so a single-defect aggregate: there is no walk behind an unreadable
|
||||||
if case .unreadableRoot = error.reason {
|
// root, and nothing for a second defect to come from.
|
||||||
|
#expect(error.defects.count == 1)
|
||||||
|
#expect(error.primary.path == ".")
|
||||||
|
if case .unreadableRoot = error.primary.reason {
|
||||||
// expected
|
// expected
|
||||||
} else {
|
} else {
|
||||||
Issue.record("expected .unreadableRoot, got \(error.reason)")
|
Issue.record("expected .unreadableRoot, got \(error.primary.reason)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,13 +686,13 @@ struct BoardLoaderFailFastTests {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try BoardLoader.load(boardRoot: fixture.root)
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
Issue.record("expected a BoardLoadError but load succeeded")
|
Issue.record("expected a BoardLoadFailure but load succeeded")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(error.path == "index.md")
|
#expect(error.primary.path == "index.md")
|
||||||
if case .unparseableYAML = error.reason {
|
if case .unparseableYAML = error.primary.reason {
|
||||||
// expected
|
// expected
|
||||||
} else {
|
} else {
|
||||||
Issue.record("expected .unparseableYAML, got \(error.reason)")
|
Issue.record("expected .unparseableYAML, got \(error.primary.reason)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +765,160 @@ struct BoardLoaderFailFastTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Collect-all, and the skip channel
|
||||||
|
|
||||||
|
/// **"The loader collects every fail-fast defect in the walk rather than stopping at the first"**
|
||||||
|
/// (01-storage-format.md § Malformed input, settled 2026-07-31), and its other half: "Skip is
|
||||||
|
/// user-consented tolerance … per-open decisions, never persisted".
|
||||||
|
///
|
||||||
|
/// `Fixtures/Malformed/many-defects.kanban` and `skippable-defects.kanban` are the disk-backed golden
|
||||||
|
/// boards for both; these are the edges a fixture cannot hold — the trash container (no fixture board
|
||||||
|
/// carries a `.trash/`) and the environmental failures.
|
||||||
|
@Suite("BoardLoader ▸ collect-all and skip")
|
||||||
|
struct BoardLoaderCollectAndSkipTests {
|
||||||
|
|
||||||
|
/// `.trash/` is the walk's last container, so its defects land last — the ordering claim stated
|
||||||
|
/// where a fixture cannot state it.
|
||||||
|
@Test("Trash defects collect after the lanes, in walk order")
|
||||||
|
func trashDefectsCollectLast() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
let card = "20000000-0000-4000-8000-000000000002"
|
||||||
|
let entry = "30000000-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("\(lane)/\(card)", "schema: 9\norder: 1024\n")
|
||||||
|
try fixture.index(".trash/\(entry)", "schema: 7\nkind: card\n")
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
Issue.record("a board with two fail-fast defects loaded")
|
||||||
|
} catch {
|
||||||
|
#expect(error.defects == [
|
||||||
|
BoardLoadError(path: "\(lane)/\(card)/index.md", reason: .schemaNewerThanApp(found: 9)),
|
||||||
|
BoardLoadError(path: ".trash/\(entry)/index.md", reason: .schemaNewerThanApp(found: 7)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trash entry is skippable like anything else below the root, and skipping it takes it out of
|
||||||
|
/// the container rather than out of the board.
|
||||||
|
@Test("A skipped trash entry leaves the trash and the board loads")
|
||||||
|
func aSkippedTrashEntryLeavesTheTrash() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
let kept = "20000000-0000-4000-8000-000000000002"
|
||||||
|
let broken = "30000000-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index(".trash/\(kept)", "schema: 1\nkind: card\ntitle: Kept\n")
|
||||||
|
try fixture.index(".trash/\(broken)", "schema: 7\nkind: card\ntitle: Too New\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(
|
||||||
|
boardRoot: fixture.root, skipping: [".trash/\(broken)/index.md"])
|
||||||
|
|
||||||
|
#expect(result.model.trash.map(\.id.rawValue) == [kept])
|
||||||
|
#expect(result.warnings == [.userSkipped(path: ".trash/\(broken)/index.md")])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Environmental failures stay immediate**: there is no walk behind a root that is a file, so
|
||||||
|
/// the aggregate has exactly one defect and no board was ever read.
|
||||||
|
@Test("An environmental failure is a single-defect aggregate")
|
||||||
|
func environmentalFailuresAreSingleDefect() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let fileRoot = fixture.root.appendingPathComponent("not-a-folder")
|
||||||
|
try "hello".write(to: fileRoot, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fileRoot)
|
||||||
|
Issue.record("a file loaded as a board")
|
||||||
|
} catch {
|
||||||
|
#expect(error.defects == [BoardLoadError(path: ".", reason: .notADirectory)])
|
||||||
|
// And the environmental path is unskippable too — a skip set naming it changes nothing.
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fileRoot, skipping: ["."])
|
||||||
|
Issue.record("a skip set talked the loader into loading a file as a board")
|
||||||
|
} catch {
|
||||||
|
#expect(error.defects == [BoardLoadError(path: ".", reason: .notADirectory)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A skip is per-open and nothing else**: the same loader call without the set refuses again,
|
||||||
|
/// which is the ruling's "the next open of a still-broken board presents the surface again"
|
||||||
|
/// stated as an assertion. Nothing is written, so nothing can remember.
|
||||||
|
@Test("A skip persists nowhere — the next walk refuses again")
|
||||||
|
func skipsArePerOpen() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 2\norder: 1024\n")
|
||||||
|
|
||||||
|
let skipped = try BoardLoader.load(boardRoot: fixture.root, skipping: ["\(lane)/index.md"])
|
||||||
|
#expect(skipped.model.lanes.isEmpty)
|
||||||
|
|
||||||
|
expectFailure(.schemaNewerThanApp(found: 2), path: "\(lane)/index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root's four defect shapes are all collected — including the two the design's class list
|
||||||
|
/// does not name (`malformedSchema` at the root, and its below-root twin, covered above) — and
|
||||||
|
/// the walk still reports what it found underneath.
|
||||||
|
@Test("A malformed root schema is collected, and the walk continues under it")
|
||||||
|
func aMalformedRootSchemaStillWalks() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
try fixture.index("", "schema: one\n")
|
||||||
|
try fixture.index(lane, "schema: 4\norder: 1024\n")
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
Issue.record("a board with a malformed root schema loaded")
|
||||||
|
} catch {
|
||||||
|
#expect(error.defects == [
|
||||||
|
BoardLoadError(path: "index.md", reason: .malformedSchema(raw: "one")),
|
||||||
|
BoardLoadError(path: "\(lane)/index.md", reason: .schemaNewerThanApp(found: 4)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A root with no `index.md` at all does not end the walk either: the lanes below it are found by
|
||||||
|
/// folder shape, so the surface can state the root's minted repair *and* what else is wrong in
|
||||||
|
/// the same pass.
|
||||||
|
@Test("A missing root index does not end the walk")
|
||||||
|
func aMissingRootIndexDoesNotEndTheWalk() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
try fixture.index(lane, "schema: 3\norder: 1024\n")
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
Issue.record("a board with no root index loaded")
|
||||||
|
} catch {
|
||||||
|
#expect(error.defects == [
|
||||||
|
BoardLoadError(path: "index.md", reason: .boardRootMissingIndex),
|
||||||
|
BoardLoadError(path: "\(lane)/index.md", reason: .schemaNewerThanApp(found: 3)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - `order` and `schema` optional below the board root
|
// MARK: - `order` and `schema` optional below the board root
|
||||||
|
|
||||||
/// **The append-at-end reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31): below the
|
/// **The append-at-end reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31): below the
|
||||||
@@ -925,9 +1083,9 @@ struct BoardLoaderEncodingTests {
|
|||||||
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
|
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
|
||||||
|
|
||||||
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
|
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
|
||||||
guard let loadError = error as? BoardLoadError,
|
guard let failure = error as? BoardLoadFailure,
|
||||||
case .unparseableYAML = loadError.reason else { return false }
|
case .unparseableYAML = failure.primary.reason else { return false }
|
||||||
return loadError.path == "index.md"
|
return failure.primary.path == "index.md"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,8 +1100,8 @@ struct BoardLoaderEncodingTests {
|
|||||||
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
|
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
|
||||||
|
|
||||||
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
|
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
|
||||||
guard let loadError = error as? BoardLoadError,
|
guard let failure = error as? BoardLoadFailure,
|
||||||
case let .unparseableYAML(message, _) = loadError.reason else { return false }
|
case let .unparseableYAML(message, _) = failure.primary.reason else { return false }
|
||||||
return message == "file is not UTF-8"
|
return message == "file is not UTF-8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,12 +204,12 @@ struct BoardStoreRegistryTests {
|
|||||||
try fixture.item(Ident.lane1, brokenIndex)
|
try fixture.item(Ident.lane1, brokenIndex)
|
||||||
let registry = BoardStoreRegistry()
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
_ = try registry.acquire(fixture.root)
|
_ = try registry.acquire(fixture.root)
|
||||||
Issue.record("expected the load to fail fast")
|
Issue.record("expected the load to fail fast")
|
||||||
} catch {
|
} catch {
|
||||||
if case .unparseableYAML = error.reason {} else {
|
if case .unparseableYAML = error.primary.reason {} else {
|
||||||
Issue.record("expected unparseable YAML, got \(error.reason)")
|
Issue.record("expected unparseable YAML, got \(error.primary.reason)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,15 +265,15 @@ struct BoardStoreRegistryTests {
|
|||||||
let missing = FileManager.default.temporaryDirectory
|
let missing = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("no-such-board-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("no-such-board-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
_ = try registry.acquire(missing)
|
_ = try registry.acquire(missing)
|
||||||
Issue.record("expected a missing root to fail")
|
Issue.record("expected a missing root to fail")
|
||||||
} catch {
|
} catch {
|
||||||
// The identity read fails first, and the registry deliberately says nothing about that —
|
// The identity read fails first, and the registry deliberately says nothing about that —
|
||||||
// it lets `BoardStore`'s load produce the honest reason.
|
// it lets `BoardStore`'s load produce the honest reason.
|
||||||
#expect(error.path == ".")
|
#expect(error.primary.path == ".")
|
||||||
if case .unreadableRoot = error.reason {} else {
|
if case .unreadableRoot = error.primary.reason {} else {
|
||||||
Issue.record("expected an unreadable root, got \(error.reason)")
|
Issue.record("expected an unreadable root, got \(error.primary.reason)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#expect(registry.openBoardCount == 0)
|
#expect(registry.openBoardCount == 0)
|
||||||
|
|||||||
@@ -167,13 +167,13 @@ struct BoardStoreTests {
|
|||||||
|
|
||||||
// There is nothing to fall back on before the first load, so every later rule — the banner,
|
// There is nothing to fall back on before the first load, so every later rule — the banner,
|
||||||
// the lock, "a failed reload never replaces a good snapshot" — has no meaning here.
|
// the lock, "a failed reload never replaces a good snapshot" — has no meaning here.
|
||||||
do throws(BoardLoadError) {
|
do throws(BoardLoadFailure) {
|
||||||
_ = try BoardStore(rootURL: fixture.root)
|
_ = try BoardStore(rootURL: fixture.root)
|
||||||
Issue.record("expected the initial load to fail")
|
Issue.record("expected the initial load to fail")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(error.path == indexPath(Ident.lane1))
|
#expect(error.primary.path == indexPath(Ident.lane1))
|
||||||
if case .unparseableYAML = error.reason {} else {
|
if case .unparseableYAML = error.primary.reason {} else {
|
||||||
Issue.record("expected unparseable YAML, got \(error.reason)")
|
Issue.record("expected unparseable YAML, got \(error.primary.reason)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@ struct BoardStoreTests {
|
|||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
#expect(store.snapshot == lastGood, "a failed reload never replaces a good snapshot")
|
#expect(store.snapshot == lastGood, "a failed reload never replaces a good snapshot")
|
||||||
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
|
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
|
||||||
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")), "warnings describe the snapshot on screen, so they stay with it")
|
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")), "warnings describe the snapshot on screen, so they stay with it")
|
||||||
|
|
||||||
// Transient breakage self-heals: the watcher kept watching and the fix arrives as an
|
// Transient breakage self-heals: the watcher kept watching and the fix arrives as an
|
||||||
@@ -347,7 +347,7 @@ struct BoardStoreTests {
|
|||||||
|
|
||||||
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
||||||
#expect(store.isReadOnly)
|
#expect(store.isReadOnly)
|
||||||
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
|
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
|
||||||
#expect(store.snapshot == lastGood)
|
#expect(store.snapshot == lastGood)
|
||||||
|
|
||||||
// Writes are refused, and refused *before* the bracket opens — a refusal must not leave the
|
// Writes are refused, and refused *before* the bracket opens — a refusal must not leave the
|
||||||
@@ -404,7 +404,7 @@ struct BoardStoreTests {
|
|||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
#expect(store.reloadGeneration == 2, "two walks: the one in flight, then the one the operation owed")
|
#expect(store.reloadGeneration == 2, "two walks: the one in flight, then the one the operation owed")
|
||||||
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
|
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1, Ident.card1))
|
||||||
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ private func expectFixtureFailure(
|
|||||||
do {
|
do {
|
||||||
_ = try loadFixture(relativePath)
|
_ = try loadFixture(relativePath)
|
||||||
Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded")
|
Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded")
|
||||||
} catch let error as BoardLoadError {
|
} catch let failure as BoardLoadFailure {
|
||||||
|
// Each `Malformed/` board is minimal — "one broken thing" — so the aggregate holds exactly
|
||||||
|
// one defect, and asserting the count is what keeps that authoring rule true.
|
||||||
|
#expect(failure.defects.count == 1, "\(relativePath): expected one defect, got \(failure.defects)")
|
||||||
|
let error = failure.primary
|
||||||
#expect(error.path == path, "\(relativePath): wrong path in error")
|
#expect(error.path == path, "\(relativePath): wrong path in error")
|
||||||
#expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
|
#expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("\(relativePath): expected a BoardLoadError, got \(error)")
|
Issue.record("\(relativePath): expected a BoardLoadFailure, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,3 +567,220 @@ struct FixtureMalformedTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Malformed/many-defects.kanban — the collect-all board
|
||||||
|
|
||||||
|
/// **The loader collects every fail-fast defect in the walk rather than stopping at the first**
|
||||||
|
/// (01-storage-format.md § Malformed input, settled 2026-07-31 — the decision surface's whole
|
||||||
|
/// premise: "one aggregated surface presents them all", never a chain of modals).
|
||||||
|
///
|
||||||
|
/// Three classes on one board, on real disk: the root's own missing `schema`, a card written by a
|
||||||
|
/// newer Lanework, and a lane whose frontmatter will not parse.
|
||||||
|
private enum ManyDefects {
|
||||||
|
static let board = "Malformed/many-defects.kanban"
|
||||||
|
|
||||||
|
static let intactLane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
static let brokenLane = "30000000-0000-4000-8000-000000000002"
|
||||||
|
static let tailLane = "50000000-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
static let intactCard = "20000000-0000-4000-8000-000000000001"
|
||||||
|
static let newerCard = "20000000-0000-4000-8000-000000000002"
|
||||||
|
/// Broken, and behind the broken lane — never enumerated, so never in the aggregate.
|
||||||
|
static let cardBehindTheBrokenLane = "40000000-0000-4000-8000-000000000009"
|
||||||
|
static let tailCard = "60000000-0000-4000-8000-000000000004"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureManyDefectsTests {
|
||||||
|
|
||||||
|
/// The whole list, in walk order, asserted as a list — the ordering *is* the contract, because it
|
||||||
|
/// is what the surface groups top-down and what `primary` reads off.
|
||||||
|
@Test func everyFailFastDefectIsCollectedInWalkOrder() {
|
||||||
|
do {
|
||||||
|
_ = try loadFixture(ManyDefects.board)
|
||||||
|
Issue.record("a board with three fail-fast defects loaded")
|
||||||
|
} catch let failure as BoardLoadFailure {
|
||||||
|
// The paths, as a list, because the *order* is the contract: the root first — its own
|
||||||
|
// `schema` is the this-really-is-a-board gate, and the walk continued past it because
|
||||||
|
// nothing below reads the root's document — then lanes in folder-name order with each
|
||||||
|
// lane's cards inside it.
|
||||||
|
#expect(failure.defects.map(\.path) == [
|
||||||
|
"index.md",
|
||||||
|
"\(ManyDefects.intactLane)/\(ManyDefects.newerCard)/index.md",
|
||||||
|
"\(ManyDefects.brokenLane)/index.md",
|
||||||
|
])
|
||||||
|
guard failure.defects.count == 3 else { return }
|
||||||
|
#expect(failure.defects[0].reason == .missingSchema)
|
||||||
|
#expect(failure.defects[1].reason == .schemaNewerThanApp(found: 2))
|
||||||
|
// The lane's reason is matched by shape rather than by the parser's exact sentence,
|
||||||
|
// which is the YAML engine's wording and not this suite's to pin.
|
||||||
|
if case .unparseableYAML = failure.defects[2].reason {} else {
|
||||||
|
Issue.record("expected unparseable YAML on the lane, got \(failure.defects[2].reason)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A broken lane takes its subtree with it, and that is the designed loop.** The card under the
|
||||||
|
/// unparseable lane is broken too and is deliberately absent from the aggregate: it was never
|
||||||
|
/// enumerated. Repair the lane, Re-check — "re-runs the whole walk" — and the next aggregate
|
||||||
|
/// carries it. Deeper defects surface one repair at a time, by design, not by omission.
|
||||||
|
@Test func aBrokenLanesSubtreeIsNeverEnumerated() {
|
||||||
|
do {
|
||||||
|
_ = try loadFixture(ManyDefects.board)
|
||||||
|
Issue.record("a board with three fail-fast defects loaded")
|
||||||
|
} catch let failure as BoardLoadFailure {
|
||||||
|
#expect(!failure.defects.contains { $0.path.contains(ManyDefects.cardBehindTheBrokenLane) })
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `primary` is the walk's first defect — the outermost problem, which is the one a one-line
|
||||||
|
/// surface should name.
|
||||||
|
@Test func primaryIsTheRootsOwnDefect() {
|
||||||
|
do {
|
||||||
|
_ = try loadFixture(ManyDefects.board)
|
||||||
|
Issue.record("a board with three fail-fast defects loaded")
|
||||||
|
} catch let failure as BoardLoadFailure {
|
||||||
|
#expect(failure.primary == BoardLoadError(path: "index.md", reason: .missingSchema))
|
||||||
|
#expect(failure.description.hasSuffix("(and 2 more)"))
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The root is unskippable** (01-storage-format.md § Malformed input): the surface never offers
|
||||||
|
/// Skip there — the root defects have minted repairs, and a newer root schema is Cancel-only — so
|
||||||
|
/// a set naming the root's `index.md` is ignored rather than obeyed. Policed in the loader, which
|
||||||
|
/// is what makes a board with no root document impossible to construct.
|
||||||
|
@Test func aSkipSetNamingTheRootIsIgnored() {
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(
|
||||||
|
boardRoot: fixtureBoard(ManyDefects.board),
|
||||||
|
skipping: [
|
||||||
|
"index.md",
|
||||||
|
".",
|
||||||
|
"\(ManyDefects.intactLane)/\(ManyDefects.newerCard)/index.md",
|
||||||
|
"\(ManyDefects.brokenLane)/index.md",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
Issue.record("the root's own defect was skipped — a board with no schema loaded")
|
||||||
|
} catch let failure as BoardLoadFailure {
|
||||||
|
// Exactly the root's, and nothing else: the two below-root entries were honoured.
|
||||||
|
#expect(failure.defects == [BoardLoadError(path: "index.md", reason: .missingSchema)])
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Malformed/skippable-defects.kanban — the skip channel
|
||||||
|
|
||||||
|
/// **"Skip is user-consented tolerance, loudly marked"** (01-storage-format.md § Malformed input,
|
||||||
|
/// ruled 2026-07-31): a skipped item loads the board without it, the file stays on disk untouched,
|
||||||
|
/// and the opened board carries a warning naming what left. Per-open, never persisted — which this
|
||||||
|
/// suite gets for free, because the set is an argument.
|
||||||
|
private enum SkippableDefects {
|
||||||
|
static let board = "Malformed/skippable-defects.kanban"
|
||||||
|
|
||||||
|
static let intactLane = "10000000-0000-4000-8000-000000000001"
|
||||||
|
static let brokenLane = "30000000-0000-4000-8000-000000000002"
|
||||||
|
static let tailLane = "50000000-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
static let intactCard = "20000000-0000-4000-8000-000000000001"
|
||||||
|
static let newerCard = "20000000-0000-4000-8000-000000000002"
|
||||||
|
static let cardBehindTheBrokenLane = "40000000-0000-4000-8000-000000000009"
|
||||||
|
static let tailCard = "60000000-0000-4000-8000-000000000004"
|
||||||
|
|
||||||
|
static let newerCardPath = "\(intactLane)/\(newerCard)/index.md"
|
||||||
|
static let brokenLanePath = "\(brokenLane)/index.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureSkippableDefectsTests {
|
||||||
|
|
||||||
|
/// Unskipped, the board is an ordinary two-defect refusal — the baseline the skips are measured
|
||||||
|
/// against, and the proof the fixture is broken in exactly two places.
|
||||||
|
@Test func withoutSkipsTheBoardRefusesWithBothDefects() {
|
||||||
|
do {
|
||||||
|
_ = try loadFixture(SkippableDefects.board)
|
||||||
|
Issue.record("a board with two fail-fast defects loaded")
|
||||||
|
} catch let failure as BoardLoadFailure {
|
||||||
|
#expect(failure.defects.map(\.path) == [
|
||||||
|
SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath,
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip both and the board opens — **and each skipped item leaves with its whole subtree**. The
|
||||||
|
/// broken lane's own card is perfectly valid and is gone too: that is the honest cost of the
|
||||||
|
/// tolerance, and the reason the notice names what left rather than pretending nothing did.
|
||||||
|
@Test func skippingEveryDefectLoadsTheBoardWithoutThoseItems() throws {
|
||||||
|
let result = try BoardLoader.load(
|
||||||
|
boardRoot: fixtureBoard(SkippableDefects.board),
|
||||||
|
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result.model.title.value == "Skippable Defects")
|
||||||
|
#expect(result.model.lanes.map(\.id.rawValue) == [
|
||||||
|
SkippableDefects.intactLane, SkippableDefects.tailLane,
|
||||||
|
])
|
||||||
|
|
||||||
|
let intact = try #require(result.model.lanes.first { $0.id.rawValue == SkippableDefects.intactLane })
|
||||||
|
#expect(intact.cards.map(\.id.rawValue) == [SkippableDefects.intactCard])
|
||||||
|
|
||||||
|
// The subtree went with the lane, valid card and all.
|
||||||
|
let everyCard = result.model.lanes.flatMap { $0.cards.map(\.id.rawValue) }
|
||||||
|
#expect(!everyCard.contains(SkippableDefects.cardBehindTheBrokenLane))
|
||||||
|
#expect(everyCard == [SkippableDefects.intactCard, SkippableDefects.tailCard])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loud mark: one warning per skip, naming the defect's own path — which is what the surface
|
||||||
|
/// row named and what the opened board's notice resolves its Reveal in Finder against.
|
||||||
|
@Test func everySkipIsWarnedAbout() throws {
|
||||||
|
let result = try BoardLoader.load(
|
||||||
|
boardRoot: fixtureBoard(SkippableDefects.board),
|
||||||
|
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result.warnings == [
|
||||||
|
.userSkipped(path: SkippableDefects.newerCardPath),
|
||||||
|
.userSkipped(path: SkippableDefects.brokenLanePath),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A partial skip is an ordinary refusal over what is left — the surface's per-item override,
|
||||||
|
/// and the reason Skip is a set rather than a switch.
|
||||||
|
@Test func skippingOneDefectStillRefusesForTheOther() {
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(
|
||||||
|
boardRoot: fixtureBoard(SkippableDefects.board),
|
||||||
|
skipping: [SkippableDefects.newerCardPath]
|
||||||
|
)
|
||||||
|
Issue.record("the unskipped lane defect did not refuse the load")
|
||||||
|
} catch let failure as BoardLoadFailure {
|
||||||
|
#expect(failure.defects.map(\.path) == [SkippableDefects.brokenLanePath])
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Skip touches nothing on disk** — "the file stays on disk untouched, tolerated-invisible like
|
||||||
|
/// strays". Asserted on the one tree where a stray write would show up in `git status`.
|
||||||
|
@Test func aSkippedItemIsLeftExactlyWhereItIs() throws {
|
||||||
|
let root = fixtureBoard(SkippableDefects.board)
|
||||||
|
let before = try allIndexMdFiles(under: root).map(\.path).sorted()
|
||||||
|
|
||||||
|
_ = try BoardLoader.load(
|
||||||
|
boardRoot: root,
|
||||||
|
skipping: [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(try allIndexMdFiles(under: root).map(\.path).sorted() == before)
|
||||||
|
let skipped = root.appendingPathComponent(SkippableDefects.brokenLanePath)
|
||||||
|
let text = try String(contentsOf: skipped, encoding: .utf8)
|
||||||
|
#expect(text.contains("labels: [red, green"), "the skipped file was rewritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -345,7 +345,11 @@ struct UITestMalformedFixtureBoardTests {
|
|||||||
do {
|
do {
|
||||||
_ = try BoardLoader.load(boardRoot: root)
|
_ = try BoardLoader.load(boardRoot: root)
|
||||||
Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
|
Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
|
||||||
} catch let error as BoardLoadError {
|
} catch let failure as BoardLoadFailure {
|
||||||
|
// One broken file, so one defect: the fixture breaks a single card's index, and the
|
||||||
|
// aggregate is what the welcome row reads its caption off.
|
||||||
|
#expect(failure.defects.count == 1)
|
||||||
|
let error = failure.primary
|
||||||
// The path is board-relative and names the *file*, which is what the welcome row's
|
// The path is board-relative and names the *file*, which is what the welcome row's
|
||||||
// failure caption carries and what the UI test asserts against.
|
// failure caption carries and what the UI test asserts against.
|
||||||
#expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
|
#expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
|
||||||
@@ -360,10 +364,10 @@ struct UITestMalformedFixtureBoardTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The whole sentence, which is what actually reaches the user: file first, then why.
|
// The whole sentence, which is what actually reaches the user: file first, then why.
|
||||||
#expect(error.description.contains(BoardLoader.indexFileName))
|
#expect(failure.description.contains(BoardLoader.indexFileName))
|
||||||
#expect(error.description.lowercased().contains("yaml"))
|
#expect(failure.description.lowercased().contains("yaml"))
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("expected a BoardLoadError, got \(error)")
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,10 +390,10 @@ struct UITestMalformedFixtureBoardTests {
|
|||||||
do {
|
do {
|
||||||
_ = try BoardLoader.load(boardRoot: root)
|
_ = try BoardLoader.load(boardRoot: root)
|
||||||
Issue.record("the malformed board loaded")
|
Issue.record("the malformed board loaded")
|
||||||
} catch let error as BoardLoadError {
|
} catch let failure as BoardLoadFailure {
|
||||||
#expect(error.path.hasSuffix(BoardLoader.indexFileName))
|
#expect(failure.primary.path.hasSuffix(BoardLoader.indexFileName))
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("expected a BoardLoadError, got \(error)")
|
Issue.record("expected a BoardLoadFailure, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The defining consequence: anything that can read and write files is a first-clas
|
|||||||
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
|
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
|
||||||
|
|
||||||
- **Validation and healing** — one integrity service owns every rule about what a valid object is (the identity predicate and its canonical form, the per-kind field tables, the reserved-name tables, the trash's `kind` discriminator) and one engine runs every repair that writes. Defects classify into one five-verdict taxonomy — refuse, refuse writes, tolerate, coerce, heal — and the verdict decides everything downstream, so no mechanism re-reasons its posture on its own. Heals are *inline* when a gesture cannot proceed without them, *on-touch* when the defect is latent (a missing `kind` key backfills on any write that was rewriting that file anyway — never a sweep), and *scheduled* when the defect degrades the board while it stands: loose card files, legacy `deleted:` keys, a squatter on a claimed name, and a stale agent guide all run on one engine that defers under the read-only lock, re-verifies against disk before writing, never retries a failure in a loop, and fires at board open and at every reload alike.
|
- **Validation and healing** — one integrity service owns every rule about what a valid object is (the identity predicate and its canonical form, the per-kind field tables, the reserved-name tables, the trash's `kind` discriminator) and one engine runs every repair that writes. Defects classify into one five-verdict taxonomy — refuse, refuse writes, tolerate, coerce, heal — and the verdict decides everything downstream, so no mechanism re-reasons its posture on its own. Heals are *inline* when a gesture cannot proceed without them, *on-touch* when the defect is latent (a missing `kind` key backfills on any write that was rewriting that file anyway — never a sweep), and *scheduled* when the defect degrades the board while it stands: loose card files, legacy `deleted:` keys, a squatter on a claimed name, and a stale agent guide all run on one engine that defers under the read-only lock, re-verifies against disk before writing, never retries a failure in a loop, and fires at board open and at every reload alike.
|
||||||
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys — pinned by a golden fixture suite of 15 on-disk boards.
|
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys. One walk reports **every** fail-fast defect at once rather than stopping at the first — the root's own, then each lane and card in walk order, a broken lane's subtree left for the repair's re-check — so a refusal is one aggregate instead of a chain of them, and a per-open set of skipped paths (never persisted, never accepted at the root) loads the board without exactly the items the user chose to leave out. Pinned by a golden fixture suite of 17 on-disk boards.
|
||||||
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
|
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
|
||||||
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
||||||
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
|
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
|
||||||
|
|||||||
Reference in New Issue
Block a user