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