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:
@@ -64,11 +64,15 @@ private func expectFixtureFailure(
|
||||
do {
|
||||
_ = try loadFixture(relativePath)
|
||||
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(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)")
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user