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:
2026-08-01 09:12:49 -04:00
parent 94e60cd444
commit ba1726fa77
35 changed files with 897 additions and 117 deletions
+175 -17
View File
@@ -57,6 +57,8 @@ private func uuidFolderName() -> String {
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(
_ expectedReason: BoardLoadError.Reason,
path: String,
@@ -65,11 +67,10 @@ private func expectFailure(
do {
try operation()
Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded")
} catch let error as BoardLoadError {
#expect(error.path == path)
#expect(error.reason == expectedReason)
} catch let failure as BoardLoadFailure {
#expect(failure.defects == [BoardLoadError(path: path, reason: expectedReason)])
} catch {
Issue.record("expected a BoardLoadError, got \(error)")
Issue.record("expected a BoardLoadFailure, got \(error)")
}
}
@@ -658,13 +659,16 @@ struct BoardLoaderFailFastTests {
do {
_ = try BoardLoader.load(boardRoot: missing)
Issue.record("expected a BoardLoadError but load succeeded")
Issue.record("expected a BoardLoadFailure but load succeeded")
} catch {
#expect(error.path == ".")
if case .unreadableRoot = error.reason {
// Environmental, so a single-defect aggregate: there is no walk behind an unreadable
// 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
} else {
Issue.record("expected .unreadableRoot, got \(error.reason)")
Issue.record("expected .unreadableRoot, got \(error.primary.reason)")
}
}
}
@@ -682,13 +686,13 @@ struct BoardLoaderFailFastTests {
do {
_ = try BoardLoader.load(boardRoot: fixture.root)
Issue.record("expected a BoardLoadError but load succeeded")
Issue.record("expected a BoardLoadFailure but load succeeded")
} catch {
#expect(error.path == "index.md")
if case .unparseableYAML = error.reason {
#expect(error.primary.path == "index.md")
if case .unparseableYAML = error.primary.reason {
// expected
} 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
/// **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"))
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
guard let loadError = error as? BoardLoadError,
case .unparseableYAML = loadError.reason else { return false }
return loadError.path == "index.md"
guard let failure = error as? BoardLoadFailure,
case .unparseableYAML = failure.primary.reason else { return false }
return failure.primary.path == "index.md"
}
}
@@ -942,8 +1100,8 @@ struct BoardLoaderEncodingTests {
try bytes.write(to: fixture.root.appendingPathComponent("index.md"))
#expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
guard let loadError = error as? BoardLoadError,
case let .unparseableYAML(message, _) = loadError.reason else { return false }
guard let failure = error as? BoardLoadFailure,
case let .unparseableYAML(message, _) = failure.primary.reason else { return false }
return message == "file is not UTF-8"
}
}