Memoize the reload parse and short-circuit value-equal snapshots

The loader gains a ParseMemo — the previous walk's parsed documents keyed
by root-relative path, trusted on the git-index heuristic (mtime + size,
no hashing) and passed as an input so the loader stays stateless. A hit
skips exactly one file read; schema, order, coercions, dedupe, and every
directory listing run fresh, so memoized and cold walks are output-
identical (golden-corpus equivalence suite). Entries record only past the
schema gate, so a defect can never be answered from the memo.

The store skips the snapshot assignment wholesale when the fresh model is
value-equal — no @Observable churn, no render pass, no snapshotGeneration
bump — and a new landedReloads counter carries walk-completion for the
three consumers whose subject is the walk, not the snapshot: the card
window's comment thread, the comment search index, and the auto-committer's
covering gate (which now counts a completed walk as covering even when
nothing changed). Warnings and defects move on their own equality; failed
reloads bump neither counter. An injectable ParseCounter makes the
single-file-echo claim a test.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 11:38:25 -04:00
parent 5e6417e749
commit 988a7245a3
11 changed files with 1085 additions and 70 deletions
+6 -1
View File
@@ -889,8 +889,13 @@ public final class AppModel {
// await are reads of the store the composer is already diffing, which is why they are // await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how // wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`). // long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
//
// The generation the gate counts is `landedReloads` completed *walks* rather than
// applied snapshots because a value-equal reload skips the assignment and its
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
// anything to change (`BoardStore.landedReloads`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() } committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration } committer.landedReloads = { [weak store] in store?.landedReloads }
// 02-architecture.md Write-failure surfacing, through the strip the board window // 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has // already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock // stopped advancing", which is exactly what the standing suspension row says. Lock
+11 -5
View File
@@ -484,13 +484,19 @@ struct CardWindowHost: View {
attachments.isEditable = !locked attachments.isEditable = !locked
session.comments.isEditable = !locked session.comments.isEditable = !locked
} }
// **The thread re-reads on every applied snapshot** (05 The comments column: "the pane // **The thread re-reads on every landed reload** (05 The comments column: "the pane
// reloads its thread from the same FSEvents stream"). // reloads its thread from the same FSEvents stream").
// //
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the // *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` it does // store's observable surface publishes counters and a `BoardModel` it does not vend the
// not vend the changed paths, and comments are outside the snapshot entirely // changed paths, and comments are outside the snapshot entirely (01-storage-format.md
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here. // § Enhanced schema), so there is nothing to filter *on* here.
//
// **`landedReloads`, not `snapshotGeneration`**, and for that same sentence's reason: a
// comment arriving changes the tree and leaves the model value-equal, and a value-equal
// landing skips the snapshot assignment (blessed 2026-07-31). Watching the applied counter
// would mean the one kind of change this pane exists to notice is the one kind it would
// sleep through.
// Re-reading one card's thread is a handful of small files and happens only while a card // Re-reading one card's thread is a handful of small files and happens only while a card
// window is open; filtering would mean either widening the store's surface to carry paths, // window is open; filtering would mean either widening the store's surface to carry paths,
// or the pane keeping its own watcher a second stream over the same tree, which the // or the pane keeping its own watcher a second stream over the same tree, which the
@@ -499,7 +505,7 @@ struct CardWindowHost: View {
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from // ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()` // its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
// already did the opening read, after the residue sweep that has to precede it. // already did the opening read, after the residue sweep that has to precede it.
.onChange(of: store.snapshotGeneration) { _, _ in .onChange(of: store.landedReloads) { _, _ in
session.comments.reload() session.comments.reload()
} }
} else { } else {
+11 -4
View File
@@ -142,14 +142,21 @@ public final class GitAutoCommitter {
@ObservationIgnored @ObservationIgnored
public var awaitReloadQuiescence: (@MainActor () async -> Void)? public var awaitReloadQuiescence: (@MainActor () async -> Void)?
/// **Which generation the board `currentSnapshot` answers with is at** /// **How many tree walks the board has landed** `BoardStore.landedReloads`, incremented by
/// `BoardStore.snapshotGeneration`, incremented by every landed reload. /// every reload that completed with a snapshot in hand.
///
/// **The walk, not the applied snapshot**, and the distinction is load-bearing since 2026-07-31:
/// a reload whose tree turned out to be value-equal skips the snapshot assignment and its counter
/// (02-architecture.md § Live-reload resilience), and a gate watching *that* counter would sit out
/// its whole deadline on a flush whose covering walk had already landed. What covers a flush is a
/// walk that started after its writes reached disk, and a
/// completed walk covers them whether or not it found anything different to show.
/// ///
/// `nil` the closure absent, or answering `nil` because the store has gone means there is no /// `nil` the closure absent, or answering `nil` because the store has gone means there is no
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless /// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
/// committer. /// committer.
@ObservationIgnored @ObservationIgnored
public var snapshotGeneration: (@MainActor () -> Int?)? public var landedReloads: (@MainActor () -> Int?)?
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot /// **How long an explicit flush waits for its covering reload** before composing from the snapshot
/// it already has. /// it already has.
@@ -563,7 +570,7 @@ public final class GitAutoCommitter {
/// after the later of them 06's own "practical cushion", doing the job it is enough for and /// after the later of them 06's own "practical cushion", doing the job it is enough for and
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite. /// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
private func awaitCoveringSnapshot() async { private func awaitCoveringSnapshot() async {
guard holdsUncoveredWrites, let read = snapshotGeneration else { return } guard holdsUncoveredWrites, let read = landedReloads else { return }
await awaitReloadQuiescence?() await awaitReloadQuiescence?()
// Re-read the gate: the quiescence may itself have been the covering landing. // Re-read the gate: the quiescence may itself have been the covering landing.
guard holdsUncoveredWrites, let base = read() else { return } guard holdsUncoveredWrites, let base = read() else { return }
+149 -25
View File
@@ -230,15 +230,47 @@ public final class BoardStore: HealHost {
/// path see the type's doc comment for why. A failed reload leaves it exactly as it was. /// path see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel public private(set) var snapshot: BoardModel
/// How many snapshots this store has applied, ever a counter, not a version. /// How many snapshots this store has **applied**, ever a counter, not a version.
/// ///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold): /// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
/// a drop's overlay stands until "the next snapshot application on that store", and *application* /// a drop's overlay stands until the next snapshot application on that store, and the whole point
/// is the event, not change. A reload that produced an identical model still ends the round trip /// of the counter is that it is an *event* the overlay can watch rather than a value it would have
/// the overlay was covering comparing `snapshot` values would leave the overlay standing /// to diff.
/// exactly when the write turned out to be a no-op. ///
/// **A value-equal reload does not bump it** (02-architecture.md § Live-reload resilience,
/// blessed 2026-07-31): "the store skips the assignment entirely when the fresh snapshot equals
/// the current one so 'costs nothing visible' becomes *costs nothing*". Bumping
/// an observed counter is itself a render pass, so the skip has to cover this line too or it
/// covers nothing.
///
/// The hold tolerates that, and provably: a hold is only ever armed by a drop whose write actually
/// rearranged something `moveCards`/`moveLane` refuse a no-op arrangement *before* they open a
/// bracket, so a drop that would land value-equal never writes, never reloads, and its hold is
/// already the watchdog's to retire (`CommittedHold.timeout`) exactly as it was before the skip
/// existed. Anything whose subject is the *walk* rather than the snapshot reads `landedReloads`
/// instead.
public private(set) var snapshotGeneration: Int = 0 public private(set) var snapshotGeneration: Int = 0
/// How many tree walks this store has **landed with a snapshot in hand**, ever bumped by every
/// successful reload, whether or not the snapshot it produced was different enough to assign.
///
/// The counter for everything whose subject is the *walk*, which is everything that lives outside
/// the snapshot and therefore cannot be inferred from it:
///
/// - **Comments.** They are not in `BoardModel` at all (01-storage-format.md § Enhanced schema),
/// so a foreign comment arriving changes the tree, changes nothing in the snapshot, and must
/// still refresh the card window's thread and board search's comment index. Keying either on
/// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an
/// optimization.
/// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what
/// covers a flush is a *walk* that started after its writes hit disk, and a completed walk
/// covers them whether or not the tree turned out to differ. A gate waiting on the applied
/// counter would spin out its whole deadline on any value-equal landing.
///
/// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and
/// refreshes nothing the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
public private(set) var landedReloads: Int = 0
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless /// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always /// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
/// describe the tree currently on screen. /// describe the tree currently on screen.
@@ -488,6 +520,41 @@ public final class BoardStore: HealHost {
@ObservationIgnored @ObservationIgnored
private var reloadInFlight = false private var reloadInFlight = false
/// **The last walk's parsed documents** (`BoardLoader.ParseMemo`, blessed 2026-07-31
/// 02-architecture.md § Live-reload resilience): what the next reload hands the loader so an
/// unchanged `index.md` is not re-read and re-parsed for the hundredth time.
///
/// **The store is where it lives because the store is what has a *previous* walk.** The loader is
/// stateless statics and the memo is an argument to `load`, never hidden state inside it; the
/// first load of a board has no memo and walks cold, and every reload after it carries the one
/// the walk before produced.
///
/// **Bookkeeping, not observable state** `@ObservationIgnored`, and updated by every successful
/// landing whether or not the snapshot was assigned. A value-equal reload skips the assignment
/// precisely *because* nothing moved, and the memo it produced is the freshest set of stamps
/// there is; dropping it there would make every second reload cold.
///
/// A failed reload leaves it alone: there is no `LoadResult` to take one from, and a memo keyed by
/// path plus mtime plus size is safe to hold indefinitely every entry it can no longer answer
/// for simply misses.
///
/// Internal rather than `private` for `reloadGeneration`'s reason: what the memo holds is the
/// claim, and a suite that could not read it would be asserting the effect of something it had to
/// take on faith.
@ObservationIgnored
private(set) var parseMemo = BoardLoader.ParseMemo()
/// **What each reload's walk actually read** (`BoardLoader.ParseCounter`) the seam that makes
/// "an unchanged tree re-parses nothing" a test rather than an intention.
///
/// `nil` in production, and the walk pays nothing for it there; a suite hangs a counter here and
/// reads it after `awaitQuiescence()`. The third seam this type keeps, on `loadBarrier`'s and
/// `announce`'s terms exactly: the *rules* are pure functions elsewhere, and what only a seam can
/// make assertable is the wiring here, that the store hands the previous walk's memo to the
/// next walk at all.
@ObservationIgnored
var parseCounter: BoardLoader.ParseCounter?
/// A reload owed but not started, because one was already running when the signal arrived a /// A reload owed but not started, because one was already running when the signal arrived a
/// **flag, not a queue**: any number of signals during one walk coalesce into exactly one /// **flag, not a queue**: any number of signals during one walk coalesce into exactly one
/// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is /// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is
@@ -646,6 +713,8 @@ public final class BoardStore: HealHost {
self.snapshot = result.model self.snapshot = result.model
self.loadWarnings = result.warnings self.loadWarnings = result.warnings
self.defects = result.defects self.defects = result.defects
// The opening walk was cold by definition; what it parsed is the first reload's memo.
self.parseMemo = result.memo
self.skippedPaths = skipping self.skippedPaths = skipping
self.reloadFailure = nil self.reloadFailure = nil
self.readOnlyLock = nil self.readOnlyLock = nil
@@ -720,6 +789,12 @@ public final class BoardStore: HealHost {
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's // **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
// decision stands for the session, so a reload sees the board the user chose to open. // decision stands for the session, so a reload sees the board the user chose to open.
let skipping = skippedPaths let skipping = skippedPaths
// **The previous walk's parse, offered to this one** (`parseMemo`). Read on the main actor
// and carried into the walk as a value, exactly like `skipping` and the ranker: the loader
// stays stateless statics and this reload's memo is a fact about a walk that already
// finished, not a channel back into one that has not.
let memo = parseMemo
let counter = parseCounter
reloadInFlight = true reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))") Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
@@ -729,7 +804,12 @@ public final class BoardStore: HealHost {
let outcome: Result<LoadResult, BoardLoadFailure> let outcome: Result<LoadResult, BoardLoadFailure>
do throws(BoardLoadFailure) { do throws(BoardLoadFailure) {
outcome = .success(try BoardLoader.load( outcome = .success(try BoardLoader.load(
boardRoot: root, skipping: skipping, historyRanker: historyRanker)) boardRoot: root,
skipping: skipping,
historyRanker: historyRanker,
memo: memo,
counter: counter
))
} catch { } catch {
outcome = .failure(error) outcome = .failure(error)
} }
@@ -841,15 +921,40 @@ public final class BoardStore: HealHost {
} }
facts.vanishedFocus = focus.vanished facts.vanishedFocus = focus.vanished
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`). // **The memo rides the reload, not the snapshot** (`parseMemo`): bookkeeping, so it is
// "User-initiated structural changes animate; foreign changes snap" cannot live at the // taken from every landing the value-equal ones most of all, since those are exactly
// call sites here the way it did in the pathfinder the one-way flow means the user's // the walks whose stamps are freshest and whose successor would otherwise go cold.
// own delete arrives back through the watcher exactly like an agent's edit so it lives parseMemo = result.memo
// at the *reload*, whose origin is already classified. `Motion` owns which origins
// perform and in which voice; this store owns only the assignment they wrap. // **The value-equal reconcile skips the assignment entirely** (02-architecture.md
// § Live-reload resilience, blessed 2026-07-31): "assigning an equal tree into an
// `@Observable` property still costs a render pass, so 'costs nothing visible' becomes
// *costs nothing*".
// //
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare assignment, // It is a skip of the **observable assignment and nothing else**. Everything a landing
// which is what the snapping origins and the Reduce Motion variant both want. // owes the memo above, the landing count, the warnings and defects (which describe the
// *tree*, not the model, and can move while the model does not: a new stray folder, a
// loose file appearing beside an untouched `index.md`), the breakage clearing, the
// comment index, the lock reconciliation, the display write-through, the heals, the
// announcement and the commit seam happens below on every landing, equal or not.
//
// What the guard covers is precisely what an equal model makes vacuous: `transient
// .resolve(against:)` re-grounds state against a tree that did not move, and
// `recoverFocus` is handed a `.survived` outcome by construction (`focusOutcome` over two
// equal models can find nothing vanished), so skipping them changes no state a caller
// could observe only the render pass they would have cost.
if result.model != snapshot {
// **The motion language's one decision point** (03-board-ui.md § Motion, via
// `Motion`). "User-initiated structural changes animate; foreign changes snap" cannot
// live at the call sites here the way it did in the pathfinder the one-way flow
// means the user's own delete arrives back through the watcher exactly like an
// agent's edit so it lives at the *reload*, whose origin is already classified.
// `Motion` owns which origins perform and in which voice; this store owns only the
// assignment they wrap.
//
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare
// assignment, which is what the snapping origins and the Reduce Motion variant both
// want.
withAnimation(Motion.reloadAnimation( withAnimation(Motion.reloadAnimation(
origin: origin, origin: origin,
endsBracketedOperation: endsWholesaleOperation, endsBracketedOperation: endsWholesaleOperation,
@@ -857,7 +962,6 @@ public final class BoardStore: HealHost {
)) { )) {
snapshot = result.model snapshot = result.model
snapshotGeneration += 1 snapshotGeneration += 1
loadWarnings = result.warnings
// The one place transient state is re-grounded. It goes last, after `snapshot` is // The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a // the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree. // selection still pointing at the old tree.
@@ -870,24 +974,39 @@ public final class BoardStore: HealHost {
// The comment index' current answer rides along, because the filter half of the // The comment index' current answer rides along, because the filter half of the
// re-grounding is the *whole* filter (04 Search, re-ruled 2026-07-29) a card // re-grounding is the *whole* filter (04 Search, re-ruled 2026-07-29) a card
// matching only through its comments must not be evicted from the selection by a // matching only through its comments must not be evicted from the selection by a
// reload that asked a narrower question. The index is re-swept just below, outside // reload that asked a narrower question. The index is re-swept just below,
// the transaction, and its landing re-runs this constraint through `onRefine`. // outside the transaction, and its landing re-runs this constraint through
// `onRefine`.
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards) transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
// And *then* the recovery, on top of the set rule rather than instead of it: the // And *then* the recovery, on top of the set rule rather than instead of it: the
// resolution leaves an emptied selection wherever the focused item used to be, and // resolution leaves an emptied selection wherever the focused item used to be,
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's // and this is 10-accessibility.md's answer to the hole ("focus recovers to the
// lane walks up then sideways"). Inside the same transaction for the highlight's // card's lane walks up then sideways"). Inside the same transaction for the
// sake, exactly like the resolution it follows. // highlight's sake, exactly like the resolution it follows.
recoverFocus(focus.recovery) recoverFocus(focus.recovery)
} }
// Outside it, equally deliberately 03 keys transactions narrowly, and the banner }
// **The walk landed, assignment or not** the counter everything outside the snapshot
// watches (`landedReloads`). Bumped here rather than beside `snapshotGeneration` above
// precisely because it is not about the snapshot.
landedReloads += 1
// Outside the transaction, deliberately 03 keys transactions narrowly, and the banner
// conditions are not board structure. A lock clearing is not a thing the strip should // conditions are not board structure. A lock clearing is not a thing the strip should
// slide out on the back of a card being deleted. // slide out on the back of a card being deleted.
// //
// Each guarded by its own equality rather than by the snapshot's, because each answers a
// different question about the tree: a stray folder appearing changes `loadWarnings` with
// the model untouched, and a loose file appearing beside an unedited `index.md` changes
// `defects` the same way. An unguarded assignment of an equal array is the render pass
// the skip above exists to avoid, one property further down.
//
// Breakage always heals on a success it *is* the claim "the last reload failed", and // Breakage always heals on a success it *is* the claim "the last reload failed", and
// this one did not. // this one did not.
reloadFailure = nil if loadWarnings != result.warnings { loadWarnings = result.warnings }
defects = result.defects if reloadFailure != nil { reloadFailure = nil }
if defects != result.defects { defects = result.defects }
// **The comment index' freshness signal, and its stated interim** (04-interactions.md // **The comment index' freshness signal, and its stated interim** (04-interactions.md
// Search: "kept fresh by the same FSEvents stream while a query is active"). The store has // Search: "kept fresh by the same FSEvents stream while a query is active"). The store has
// no changed-path channel the watcher reports only *that* the tree changed // no changed-path channel the watcher reports only *that* the tree changed
@@ -4268,9 +4387,14 @@ public final class BoardStore: HealHost {
guard let self else { return } guard let self else { return }
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards) transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
} }
// **`landedReloads`, not `snapshotGeneration`**: comments are outside the snapshot entirely
// (01-storage-format.md § Enhanced schema), so a comment arriving changes the tree and leaves
// the model value-equal and a value-equal landing skips the snapshot assignment. The
// freshness signal an index of window-scoped content can use is therefore the *walk*, which
// is what this counter is.
commentIndex.update( commentIndex.update(
query: transient.searchQuery, query: transient.searchQuery,
generation: snapshotGeneration, generation: landedReloads,
targets: CommentSearchIndex.targets(in: snapshot) targets: CommentSearchIndex.targets(in: snapshot)
) )
} }
+6 -1
View File
@@ -61,9 +61,14 @@ public struct CommentSearchTarget: Sendable, Equatable {
/// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no /// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no
/// changed-path channel `FolderWatcher` reports only *that* the tree changed (02-architecture.md), /// changed-path channel `FolderWatcher` reports only *that* the tree changed (02-architecture.md),
/// and the reload seam turns that into two snapshots rather than a path list so the freshness signal /// and the reload seam turns that into two snapshots rather than a path list so the freshness signal
/// available today is `BoardStore.snapshotGeneration`: **while a query is active, a landed reload /// available today is `BoardStore.landedReloads`: **while a query is active, a landed reload
/// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have /// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have
/// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce. /// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce.
///
/// The counter is the *walk's*, not the applied snapshot's, and it has to be: comments are outside the
/// snapshot, so a comment arriving leaves the model value-equal and a value-equal reload skips its
/// assignment (blessed 2026-07-31). Keyed on the applied counter, the index would sleep through the
/// one kind of change it exists to notice.
/// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one /// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one
/// exists, this is the accepted interim and is written down here rather than discovered later. /// exists, this is the accepted interim and is written down here rather than discovered later.
/// ///
+229 -12
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import Synchronization
import os import os
/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot a pure /// Walks a board's folder tree and produces an immutable `BoardModel` snapshot a pure
@@ -193,6 +194,150 @@ public enum BoardLoader: Sendable {
return GitignoreRules(parsing: text) return GitignoreRules(parsing: text)
} }
// MARK: - The parse memo
/// **One `index.md`'s git-index heuristic record** (02-architecture.md § Live-reload resilience,
/// blessed 2026-07-31: "The walk memoizes its parse, never its result").
///
/// Modification date and byte count, and deliberately nothing else: "The mtime+size trust is the
/// git-index heuristic; a writer that defeats it content changed, mtime and size both
/// preserved is outside the app's care." No content hashing, because a hash is a read of the
/// whole file and reading the whole file is the cost the memo exists to avoid.
///
/// Stat'd through `FileManager.attributesOfItem`, **never** `URL.resourceValues`, which caches
/// its answers on the `URL` instance: a cached mtime would let the memo answer from a stamp taken
/// a reload ago, which is exactly the staleness the heuristic exists to detect.
public struct FileStamp: Sendable, Equatable {
public let modified: Date
public let size: Int
/// `nil` where the file cannot be stat'd at all read as "cannot tell", and therefore as a
/// memo miss: the walk parses, exactly as it did before the memo existed.
init?(of url: URL) {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let modified = attributes[.modificationDate] as? Date,
let size = attributes[.size] as? NSNumber
else {
return nil
}
self.modified = modified
self.size = size.intValue
}
}
/// **The previous walk's parsed documents, indexed by root-relative path** the memo
/// (02-architecture.md § Live-reload resilience, blessed 2026-07-31: "the loader may reuse the
/// previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged
/// the previous snapshot *is* the memo").
///
/// **An input to `load`, never hidden state.** The loader is stateless statics and stays that
/// way: a caller that holds no memo gets a cold walk, and one that holds the last walk's memo
/// gets the same answer faster. That is the contract stated exactly "result-purity with cost
/// unspecified: same tree in, same snapshot out, and the memo can only change how fast".
///
/// ### Scope: the parse, and nothing else
///
/// A hit skips one thing opening and parsing one file. Everything *derived* from the document
/// (`schema`, `order`, the coercion trace, the identity dedupe, the trash's `kind`) is recomputed
/// from it on every walk, unchanged, which is what makes memoized and cold walks indistinguishable
/// in output rather than merely intended to be.
///
/// **Directory enumeration is never memoized**: folder discovery, attachment listings, loose-file
/// detection, the trash's entries and the noise gate are read fresh every walk, "because
/// attachment changes never touch `index.md`" a memo that covered them would go blind to
/// precisely the changes the snapshot is supposed to show.
///
/// ### A defect can never be answered from it
///
/// An entry is recorded only where the file parsed **and** its `schema` reading succeeded the
/// two steps that can produce a `BoardLoadError` at all. So a defective `index.md` is never in the
/// memo, which settles both halves of the collect-all walk's question: a file that is broken and
/// stays broken has nothing to hit and is re-read and re-collected every walk, and a file whose
/// defect was repaired moved its mtime and size and would miss anyway. The skip channel inherits
/// this by construction a skipped path *is* a defect path so a skip is recomputed from a fresh
/// parse every walk and can never be decided from a memo.
///
/// Withheld duplicate occurrences *are* recorded, and correctly so: their files parsed cleanly and
/// only the board-wide dedupe kept them out of the model, and that dedupe runs over the fresh walk
/// either way.
public struct ParseMemo: Sendable {
fileprivate struct Entry: Sendable {
let stamp: FileStamp
let document: FrontmatterDocument
}
fileprivate var entries: [String: Entry] = [:]
/// The empty memo a cold walk. The only one a caller ever constructs; every other comes
/// out of a `LoadResult`.
public init() {}
/// How many documents this memo can answer for. The walk never asks; the suites do.
public var count: Int { entries.count }
fileprivate func document(at path: String, stamp: FileStamp) -> FrontmatterDocument? {
guard let entry = entries[path], entry.stamp == stamp else { return nil }
return entry.document
}
fileprivate mutating func record(_ document: FrontmatterDocument, at path: String, stamp: FileStamp?) {
guard let stamp else { return }
entries[path] = Entry(stamp: stamp, document: document)
}
}
/// **What one walk actually read** the memo's whole claim, made assertable.
///
/// The loader's contract is result-purity with *cost unspecified*, and a cost nothing can observe
/// is a cost nothing can regress: this is the observation handle, so "a single-file echo re-parses
/// one file, not the tree" is a test rather than a hope.
///
/// Injected rather than a static tally, for `IdentityHistoryRanker`'s reason: `load` is stateless
/// statics called from several tasks at once, and a shared counter would be one mutable answer to
/// a per-walk question. `nil` every production call costs nothing at all.
public final class ParseCounter: Sendable {
/// One walk's tally: files opened and parsed, and documents answered from the memo.
public struct Counts: Sendable, Equatable {
public var parsed = 0
public var reused = 0
}
private let state = Mutex(Counts())
public init() {}
public var counts: Counts { state.withLock { $0 } }
fileprivate func noteParse() { state.withLock { $0.parsed += 1 } }
fileprivate func noteReuse() { state.withLock { $0.reused += 1 } }
}
/// One `index.md`, read through the memo the memo's only point of contact with the walk.
///
/// A hit is a document the previous walk parsed out of a file whose path, mtime and size have not
/// moved since; a miss is the ordinary `readDocument(at:path:)`, byte for byte the same call the
/// loader has always made. The stamp travels back out so the caller can record the document into
/// *this* walk's memo once its `schema` reading has succeeded `ParseMemo` states why that, and
/// not the read, is the recording point.
///
/// A file whose stamp cannot be read (`nil`) is always parsed and never recorded: "cannot tell"
/// reads as "not memoizable", which is the direction that costs a parse rather than correctness.
private static func memoizedDocument(
at url: URL,
path: String,
memo: ParseMemo?,
counter: ParseCounter?
) throws(BoardLoadError) -> (document: FrontmatterDocument, stamp: FileStamp?) {
let stamp = FileStamp(of: url)
if let stamp, let hit = memo?.document(at: path, stamp: stamp) {
counter?.noteReuse()
return (hit, stamp)
}
counter?.noteParse()
return (try readDocument(at: url, path: path), stamp)
}
// MARK: - Entry point // MARK: - Entry point
/// Walks the board and answers a snapshot or **every fail-fast defect the walk found**, as one /// Walks the board and answers a snapshot or **every fail-fast defect the walk found**, as one
@@ -231,10 +376,20 @@ public enum BoardLoader: Sendable {
/// ///
/// **Root paths are unskippable** (`unskippablePaths`) an entry naming the root's own /// **Root paths are unskippable** (`unskippablePaths`) an entry naming the root's own
/// `index.md` is ignored and the defect collected anyway. /// `index.md` is ignored and the defect collected anyway.
///
/// - Parameter memo: **the previous walk's parsed documents** (`ParseMemo`, blessed 2026-07-31).
/// `nil` a first load, a template read, a HEAD snapshot is a cold walk. Passing the last
/// walk's memo cannot change a single thing about the result, only how many files this one
/// opens; see `ParseMemo` for the scope and for why a defect can never be answered from it.
///
/// - Parameter counter: where this walk tallies what it read (`ParseCounter`). `nil` everywhere
/// but the suites.
public static func load( public static func load(
boardRoot: URL, boardRoot: URL,
skipping: Set<String> = [], skipping: Set<String> = [],
historyRanker: IdentityHistoryRanker? = nil historyRanker: IdentityHistoryRanker? = nil,
memo: ParseMemo? = nil,
counter: ParseCounter? = nil
) throws(BoardLoadFailure) -> LoadResult { ) throws(BoardLoadFailure) -> LoadResult {
// Environmental, so immediate: a root that cannot be listed has no walk to collect from. // Environmental, so immediate: a root that cannot be listed has no walk to collect from.
do throws(BoardLoadError) { do throws(BoardLoadError) {
@@ -253,6 +408,11 @@ public enum BoardLoader: Sendable {
// what `BoardLoadFailure` carries when it does not. // what `BoardLoadFailure` carries when it does not.
var failures: [BoardLoadError] = [] var failures: [BoardLoadError] = []
// **This walk's own memo, for the next one** (`ParseMemo`). Built as the walk goes and
// handed out on the `LoadResult`, so the loader keeps no state between calls: what the store
// passes back in is what came out of the walk before it.
var freshMemo = ParseMemo()
/// Records one fail-fast defect unless this open's user already consented to skipping that /// Records one fail-fast defect unless this open's user already consented to skipping that
/// exact path. /// exact path.
/// ///
@@ -277,12 +437,16 @@ public enum BoardLoader: Sendable {
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName) let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
if FileManager.default.fileExists(atPath: boardIndexURL.path) { if FileManager.default.fileExists(atPath: boardIndexURL.path) {
do throws(BoardLoadError) { do throws(BoardLoadError) {
let document = try readDocument(at: boardIndexURL, path: indexFileName) let read = try memoizedDocument(
at: boardIndexURL, path: indexFileName, memo: memo, counter: counter)
// **The root's own `schema` stays required** (01-storage-format.md § Malformed input, // **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 // 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. // the board that does not read as 1 when absent.
boardSchema = try validatedRootSchema(in: document, path: indexFileName) boardSchema = try validatedRootSchema(in: read.document, path: indexFileName)
boardDocument = document boardDocument = read.document
// Recorded past the schema gate, never before it `ParseMemo`'s "a defect can never
// be answered from it".
freshMemo.record(read.document, at: indexFileName, stamp: read.stamp)
} catch { } catch {
record(error) record(error)
} }
@@ -386,12 +550,19 @@ public enum BoardLoader: Sendable {
let lanePath = laneName + "/" + indexFileName let lanePath = laneName + "/" + indexFileName
let laneDocument: FrontmatterDocument let laneDocument: FrontmatterDocument
let laneSchema: (schema: Int, coerced: CoercedField?) let laneSchema: (schema: Int, coerced: CoercedField?)
let laneStamp: FileStamp?
// **A broken lane takes its subtree with it** (the collect-and-skip rule above): the // **A broken lane takes its subtree with it** (the collect-and-skip rule above): the
// defect is recorded, the lane's cards are not enumerated, and the repair's re-check is // defect is recorded, the lane's cards are not enumerated, and the repair's re-check is
// what surfaces whatever they were hiding. // what surfaces whatever they were hiding.
do throws(BoardLoadError) { do throws(BoardLoadError) {
laneDocument = try readDocument( let read = try memoizedDocument(
at: laneURL.appendingPathComponent(indexFileName), path: lanePath) at: laneURL.appendingPathComponent(indexFileName),
path: lanePath,
memo: memo,
counter: counter
)
laneDocument = read.document
laneStamp = read.stamp
// Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema` // 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 // 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 // coerce-tier recorded here, logged, and acted on by nothing until the file's next
@@ -401,6 +572,7 @@ public enum BoardLoader: Sendable {
record(error) record(error)
continue continue
} }
freshMemo.record(laneDocument, at: lanePath, stamp: laneStamp)
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument) let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
noteCoercions( noteCoercions(
in: laneDocument, in: laneDocument,
@@ -423,11 +595,14 @@ public enum BoardLoader: Sendable {
let card: WalkedCard let card: WalkedCard
do throws(BoardLoadError) { do throws(BoardLoadError) {
card = try parseCard(at: cardURL, path: cardRelPath) card = try parseCard(at: cardURL, path: cardRelPath, memo: memo, counter: counter)
} catch { } catch {
record(error) record(error)
continue continue
} }
// `parseCard` returning at all means the parse and the `schema` reading both
// succeeded, which is the recording point one level up spells out longhand.
freshMemo.record(card.document, at: cardRelPath + "/" + indexFileName, stamp: card.stamp)
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,
@@ -539,17 +714,25 @@ public enum BoardLoader: Sendable {
let entryPath = entryRelPath + "/" + indexFileName let entryPath = entryRelPath + "/" + indexFileName
let document: FrontmatterDocument let document: FrontmatterDocument
let schema: (schema: Int, coerced: CoercedField?) let schema: (schema: Int, coerced: CoercedField?)
let stamp: FileStamp?
// Collected and skipped, the lane arm's rule one container over: a trash entry that will // 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 // 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). // never walked to begin with (the entry is opaque by design).
do throws(BoardLoadError) { do throws(BoardLoadError) {
document = try readDocument( let read = try memoizedDocument(
at: entryURL.appendingPathComponent(indexFileName), path: entryPath) at: entryURL.appendingPathComponent(indexFileName),
path: entryPath,
memo: memo,
counter: counter
)
document = read.document
stamp = read.stamp
schema = try resolvedSchema(in: document, path: entryPath) schema = try resolvedSchema(in: document, path: entryPath)
} catch { } catch {
record(error) record(error)
continue continue
} }
freshMemo.record(document, at: entryPath, stamp: stamp)
let order = IntegrityRules.resolvedOrder(in: document) let order = IntegrityRules.resolvedOrder(in: document)
noteCoercions( noteCoercions(
in: document, in: document,
@@ -727,6 +910,7 @@ public enum BoardLoader: Sendable {
return LoadResult( return LoadResult(
model: model, model: model,
warnings: warnings, warnings: warnings,
memo: freshMemo,
defects: defects, defects: defects,
// Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing // Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing
// an id would otherwise leave a `kind` answer standing for the *other* one the exact // an id would otherwise leave a `kind` answer standing for the *other* one the exact
@@ -929,6 +1113,9 @@ public enum BoardLoader: Sendable {
/// This card's coerce-tier records for the strict fields, which only the rulebook can make /// This card's coerce-tier records for the strict fields, which only the rulebook can make
/// (a missing key leaves no trace in `document.coercedFields`). /// (a missing key leaves no trace in `document.coercedFields`).
let coercions: [CoercedField] let coercions: [CoercedField]
/// What this card's `index.md` looked like to `stat(2)` as the walk read it the key the
/// next walk's memo hit is decided by, `nil` where the file could not be stat'd at all.
let stamp: FileStamp?
var title: FieldValue<String> { document.title } var title: FieldValue<String> { document.title }
var isDeleted: Bool { !document.deleted.isMissing } var isDeleted: Bool { !document.deleted.isMissing }
@@ -961,9 +1148,25 @@ public enum BoardLoader: Sendable {
/// ///
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`. /// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has. /// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> WalkedCard { ///
/// The **attachment listing stays fresh** here, memo or no memo (`ParseMemo` Scope): a hit
/// spares this card's `index.md` read and nothing else, because an attachment arriving in
/// `attachments/` never touches `index.md` and a card whose paperclip went stale would be the
/// memo lying about the tree.
private static func parseCard(
at cardURL: URL,
path: String,
memo: ParseMemo?,
counter: ParseCounter?
) throws(BoardLoadError) -> WalkedCard {
let cardPath = path + "/" + indexFileName let cardPath = path + "/" + indexFileName
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath) let read = try memoizedDocument(
at: cardURL.appendingPathComponent(indexFileName),
path: cardPath,
memo: memo,
counter: counter
)
let document = read.document
let schema = try resolvedSchema(in: document, path: cardPath) let schema = try resolvedSchema(in: document, path: cardPath)
let order = IntegrityRules.resolvedOrder(in: document) let order = IntegrityRules.resolvedOrder(in: document)
@@ -973,7 +1176,8 @@ public enum BoardLoader: Sendable {
storedOrder: order.order, storedOrder: order.order,
attachments: attachmentNames(in: cardURL), attachments: attachmentNames(in: cardURL),
document: document, document: document,
coercions: [schema.coerced, order.coerced].compactMap { $0 } coercions: [schema.coerced, order.coerced].compactMap { $0 },
stamp: read.stamp
) )
} }
@@ -1331,6 +1535,19 @@ public struct LoadResult: Sendable {
public var model: BoardModel public var model: BoardModel
public var warnings: [LoadWarning] public var warnings: [LoadWarning]
/// **What this walk parsed, ready to be the next walk's memo** (`BoardLoader.ParseMemo`, blessed
/// 2026-07-31).
///
/// It rides out here rather than being derived from `model` for two reasons. The stamps are not
/// in the snapshot and never will be mtime and size are facts about files, not about a board
/// and the documents that *are* in the snapshot would have to be re-indexed by path to be usable,
/// which is the walk's own knowledge being thrown away and re-derived. Carrying both together
/// keeps the loader a pure function whose caller holds the whole of what the next call may reuse.
///
/// A caller that ignores it gets a cold walk every time, which is exactly what
/// `TemplateEngine`, `GitHeadSnapshot` and every first load do.
public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo()
/// **The typed defect stream** everything this walk found that is pending *work* /// **The typed defect stream** everything this walk found that is pending *work*
/// (02-architecture.md Components IntegrityRules, settled 2026-07-29). One channel, not /// (02-architecture.md Components IntegrityRules, settled 2026-07-29). One channel, not
/// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the /// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
+7
View File
@@ -162,6 +162,13 @@ struct FileDropTarget: Equatable, Sendable {
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything /// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
/// anyway. /// anyway.
/// ///
/// It watches `BoardStore.snapshotGeneration`, which since 2026-07-31 does not move for a reload whose
/// tree came back value-equal, and that is the right counter rather than a hazard: a hold is only ever
/// armed by a drop whose write actually rearranged something every drop path refuses a no-op
/// arrangement *before* it opens a write bracket (`BoardStore.moveCards`, `moveLanes`, `moveLane`)
/// so a drop that would land value-equal never writes and never reloads, and its hold is the
/// watchdog's to retire exactly as it was before the skip existed.
///
/// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write /// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no /// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
/// hand-off coming must still dissolve and let the snapshot be the authority again. /// hand-off coming must still dissolve and let the snapshot be the authority again.
+1 -1
View File
@@ -1245,7 +1245,7 @@ struct AutoCommitMessageTests {
var generation = 0 var generation = 0
var reads = 0 var reads = 0
committer.awaitReloadQuiescence = {} committer.awaitReloadQuiescence = {}
committer.snapshotGeneration = { committer.landedReloads = {
reads += 1 reads += 1
if reads == landsAfterReads { if reads == landsAfterReads {
current = (try? fixture.snapshot()) ?? current current = (try? fixture.snapshot()) ?? current
+309
View File
@@ -1233,3 +1233,312 @@ struct BoardLoaderCoercionTraceTests {
]) ])
} }
} }
// MARK: - The parse memo
private extension BoardFixture {
/// A file's modification date, forced. Used wherever a test rewrites bytes without changing the
/// byte *count*: the stamp rule is the subject there, and leaving it to the filesystem clock
/// would make the assertion a race against timestamp resolution rather than a statement about
/// mtime.
func setModified(_ relativePath: String, to date: Date) throws {
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.setAttributes(
[.modificationDate: date],
ofItemAtPath: folder.appendingPathComponent("index.md").path
)
}
}
/// A board with every container the walk has: a root, two lanes, two cards in the first, one
/// attachment, and one trashed card so "the whole tree came out of the memo" is a claim about all
/// four levels rather than about lanes.
private func memoBoard() throws -> (fixture: BoardFixture, lane: String, card: String) {
let fixture = try BoardFixture()
let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
try fixture.index("", "schema: 1\ntitle: Board\n")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Todo\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: First\n")
try fixture.index("\(lane)/cccccccc-cccc-4ccc-8ccc-cccccccccccc", "schema: 1\norder: 2048\ntitle: Second\n")
try fixture.index(".trash/dddddddd-dddd-4ddd-8ddd-dddddddddddd", "schema: 1\norder: 1024\ntitle: Gone\n")
try fixture.strayFile("\(lane)/\(card)/attachments/shot.png", contents: "png")
return (fixture, lane, card)
}
/// Every `index.md` the board above holds the number a warm walk must answer for without opening
/// one of them.
private let memoBoardIndexCount = 5
/// A whole-second modification date, forced onto a file wherever a test needs two stamps to compare
/// *exactly*. A date read back off the filesystem does not necessarily round-trip through
/// `setAttributes` bit for bit (`Date` is a `Double` and the syscall's is a `timespec`), and a test
/// about mtime equality must not turn into a test about that conversion.
private let pinnedMtime = Date(timeIntervalSince1970: 1_750_000_000)
/// **The walk memoizes its parse, never its result** (02-architecture.md § Live-reload resilience,
/// blessed 2026-07-31 `BoardLoader.ParseMemo`).
///
/// Two claims that pull in opposite directions, which is why they are pinned side by side. The memo
/// has to actually save the reads: a walk over an untouched tree opens no `index.md` at all. And it
/// has to be undetectable in the answer, for the files it saved and harder for everything it
/// deliberately does not cover, which is every directory listing the walk makes. The equivalence half
/// is stated again over the golden fixture boards (`FixtureMemoEquivalenceTests`); this suite pins the
/// mechanism file by file, where a synthetic tree can be edited between two walks.
@Suite("BoardLoader ▸ the parse memo")
struct BoardLoaderParseMemoTests {
@Test("A cold walk parses every index.md and reuses nothing")
func aColdWalkParsesEverything() throws {
let (fixture, _, _) = try memoBoard()
defer { fixture.tearDown() }
let counter = BoardLoader.ParseCounter()
let result = try BoardLoader.load(boardRoot: fixture.root, counter: counter)
#expect(counter.counts == .init(parsed: memoBoardIndexCount, reused: 0))
#expect(result.memo.count == memoBoardIndexCount)
}
@Test("A walk over an untouched tree opens no index.md at all")
func anUntouchedTreeIsAnsweredEntirelyFromTheMemo() throws {
let (fixture, _, _) = try memoBoard()
defer { fixture.tearDown() }
let cold = try BoardLoader.load(boardRoot: fixture.root)
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount))
// The whole of the claim's other half: the cheap walk and the cold one are the same walk.
#expect(warm.model == cold.model)
#expect(warm.warnings == cold.warnings)
#expect(warm.defects == cold.defects)
#expect(warm.trashKinds == cold.trashKinds)
#expect(warm.memo.count == cold.memo.count)
}
@Test("A single-file echo re-parses exactly that file")
func oneEditedFileIsTheOnlyOneReParsed() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let cold = try BoardLoader.load(boardRoot: fixture.root)
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Renamed\n")
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount - 1))
#expect(warm.model.lanes.first?.cards.first?.title.value == "Renamed")
// And the cold answer is still the answer: the memo changed the cost, nothing else.
#expect(warm.model == (try BoardLoader.load(boardRoot: fixture.root).model))
}
@Test("A same-size rewrite still re-parses, because the mtime moved")
func aSameSizeRewriteReParses() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
try fixture.setModified("\(lane)/\(card)", to: pinnedMtime)
let cold = try BoardLoader.load(boardRoot: fixture.root)
// "First" "Third": identical byte count, so `size` alone would call this unchanged.
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Third\n")
try fixture.setModified("\(lane)/\(card)", to: pinnedMtime.addingTimeInterval(1))
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount - 1))
#expect(warm.model.lanes.first?.cards.first?.title.value == "Third")
}
/// **The heuristic's stated blind spot, pinned rather than discovered** (02-architecture.md: "The
/// mtime+size trust is the git-index heuristic; a writer that defeats it content changed, mtime
/// and size both preserved is outside the app's care").
///
/// It is here so the boundary is a decision with a test on it: this is the one shape in which a
/// memoized walk and a cold walk disagree, and the design says so out loud.
@Test("A rewrite that preserves both mtime and size is trusted — the git-index heuristic's edge")
func aRewritePreservingTheStampIsTrusted() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
try fixture.setModified("\(lane)/\(card)", to: pinnedMtime)
let cold = try BoardLoader.load(boardRoot: fixture.root)
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Third\n")
try fixture.setModified("\(lane)/\(card)", to: pinnedMtime)
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount))
#expect(warm.model.lanes.first?.cards.first?.title.value == "First")
// And the very next walk that *does* see a moved stamp catches up the window is one write,
// not a standing state.
try fixture.setModified("\(lane)/\(card)", to: pinnedMtime.addingTimeInterval(1))
let next = try BoardLoader.load(boardRoot: fixture.root, memo: warm.memo)
#expect(next.model.lanes.first?.cards.first?.title.value == "Third")
}
// MARK: Directory enumeration is never memoized
@Test("An attachment arriving is seen by a walk that parsed nothing")
func attachmentListingsStayFresh() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let cold = try BoardLoader.load(boardRoot: fixture.root)
// An attachment never touches `index.md`, which is exactly why the memo must not cover the
// listing that finds it.
try fixture.strayFile("\(lane)/\(card)/attachments/second.png", contents: "png")
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount))
#expect(warm.model.lanes.first?.cards.first?.attachments == ["second.png", "shot.png"])
}
@Test("A loose file arriving beside an untouched index.md is still a defect")
func looseFileDetectionStaysFresh() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let cold = try BoardLoader.load(boardRoot: fixture.root)
#expect(cold.looseCardFiles.isEmpty)
try fixture.strayFile("\(lane)/\(card)/notes.txt", contents: "loose")
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 0, reused: memoBoardIndexCount))
#expect(warm.looseCardFiles.map(\.fileNames) == [["notes.txt"]])
}
@Test("A new card folder is discovered, and it is the only file the walk opens")
func folderDiscoveryStaysFresh() throws {
let (fixture, lane, _) = try memoBoard()
defer { fixture.tearDown() }
let cold = try BoardLoader.load(boardRoot: fixture.root)
let arrival = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
try fixture.index("\(lane)/\(arrival)", "schema: 1\norder: 4096\ntitle: Arrived\n")
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo, counter: counter)
#expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount))
#expect(warm.model.lanes.first?.cards.map(\.title.value) == ["First", "Second", "Arrived"])
}
@Test("A vanished card leaves the model, and its memo entry goes with it")
func aVanishedItemLeavesTheMemo() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let cold = try BoardLoader.load(boardRoot: fixture.root)
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent("\(lane)/\(card)"))
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: cold.memo)
#expect(warm.model.lanes.first?.cards.map(\.title.value) == ["Second"])
#expect(warm.memo.count == memoBoardIndexCount - 1)
}
// MARK: Defects can never be answered from it
@Test("A defective index.md is never memoized, so a still-broken file is re-read every walk")
func aDefectIsNeverMemoized() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let healthy = try BoardLoader.load(boardRoot: fixture.root)
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n")
// The file does not change between these two walks, and neither of them can go quiet about
// it: a defective `index.md` is never recorded, so there is nothing for a later walk to hit.
// The healthy memo still spares the four files that *did* load, which is the point the
// defect costs one read, not a cold walk.
for _ in 0..<2 {
let counter = BoardLoader.ParseCounter()
do throws(BoardLoadFailure) {
_ = try BoardLoader.load(boardRoot: fixture.root, memo: healthy.memo, counter: counter)
Issue.record("expected the broken card to fail the walk")
} catch {
#expect(error.defects.map(\.path) == ["\(lane)/\(card)/index.md"])
}
#expect(counter.counts == .init(parsed: 1, reused: memoBoardIndexCount - 1))
}
// And the aggregate is the cold aggregate, defect for defect.
do throws(BoardLoadFailure) {
_ = try BoardLoader.load(boardRoot: fixture.root, memo: healthy.memo)
Issue.record("expected the broken card to fail the walk")
} catch let warm {
do throws(BoardLoadFailure) {
_ = try BoardLoader.load(boardRoot: fixture.root)
Issue.record("expected the broken card to fail the walk")
} catch let cold {
#expect(warm.defects == cold.defects)
}
}
}
@Test("A repaired file re-parses and rejoins the board")
func aRepairedFileReParses() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let healthy = try BoardLoader.load(boardRoot: fixture.root)
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ntitle: Repaired\n")
let warm = try BoardLoader.load(boardRoot: fixture.root, memo: healthy.memo)
#expect(warm.model.lanes.first?.cards.first?.title.value == "Repaired")
}
@Test("A skip is recomputed from a fresh parse, memo or no memo")
func aSkipComposesWithTheMemo() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let healthy = try BoardLoader.load(boardRoot: fixture.root)
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n")
let skips: Set<String> = ["\(lane)/\(card)/index.md"]
let counter = BoardLoader.ParseCounter()
let warm = try BoardLoader.load(
boardRoot: fixture.root, skipping: skips, memo: healthy.memo, counter: counter)
let cold = try BoardLoader.load(boardRoot: fixture.root, skipping: skips)
// A skipped path is a defect path, so it was never in the memo and is read on every walk.
#expect(counter.counts.parsed == 1)
#expect(warm.model == cold.model)
#expect(warm.warnings == cold.warnings)
#expect(warm.warnings.contains(.userSkipped(path: "\(lane)/\(card)/index.md")))
// The skipped item is out of the board and out of the memo, both walks alike.
#expect(warm.memo.count == memoBoardIndexCount - 1)
}
@Test("A skipped path stays skipped across a memoized reload")
func aSkipHoldsAcrossReloads() throws {
let (fixture, lane, card) = try memoBoard()
defer { fixture.tearDown() }
let healthy = try BoardLoader.load(boardRoot: fixture.root)
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nlabels: [a, b\n")
let skips: Set<String> = ["\(lane)/\(card)/index.md"]
let first = try BoardLoader.load(boardRoot: fixture.root, skipping: skips, memo: healthy.memo)
let second = try BoardLoader.load(boardRoot: fixture.root, skipping: skips, memo: first.memo)
#expect(second.model == first.model)
#expect(second.warnings == first.warnings)
#expect(second.model.lanes.first?.cards.map(\.title.value) == ["Second"])
}
}
+230
View File
@@ -602,3 +602,233 @@ struct BoardStoreTests {
#expect(store.reloadGeneration == 0) #expect(store.reloadGeneration == 0)
} }
} }
// MARK: - The parse memo and the value-equal skip
/// **"The walk memoizes its parse, never its result"** and **"the store skips the assignment entirely
/// when the fresh snapshot equals the current one"** (02-architecture.md § Live-reload resilience,
/// both blessed 2026-07-31).
///
/// The loader's half is pinned in `BoardLoaderParseMemoTests` and over the golden corpus in
/// `FixtureMemoEquivalenceTests`; what only this suite can state is the *wiring* that the store
/// hands each walk the one before it (`BoardStore.parseCounter` is the seam that makes "re-parsed
/// nothing" observable at all), and that the skip is a skip of the observable assignment and of
/// nothing else the landing owes.
@MainActor
@Suite("BoardStore ▸ the parse memo and the value-equal skip")
struct BoardStoreReloadMemoTests {
/// Every `index.md` `makeBoard()` puts in the walk's way: the root, two lanes, two cards. The
/// stray `notes/` folder holds one too and is deliberately not counted a non-UUID-shaped folder
/// is never descended into, so the loader never opens it, memo or no memo.
static let indexCount = 5
/// One reload through the store's one inbound door, with a counter attached.
private func reload(_ store: BoardStore, _ origin: WatchOrigin = .foreign) async -> BoardLoader.ParseCounter.Counts {
let counter = BoardLoader.ParseCounter()
store.parseCounter = counter
store.handleWatcherEvent(.treeChanged(origin))
await store.awaitQuiescence()
store.parseCounter = nil
return counter.counts
}
@Test("A reload of an untouched tree opens no index.md and assigns no snapshot")
func anUnchangedTreeCostsNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = store.snapshot
let counts = await reload(store)
#expect(counts == .init(parsed: 0, reused: Self.indexCount))
// The skip proper: no assignment, so no `@Observable` churn and no render pass.
#expect(store.snapshotGeneration == 0)
#expect(store.snapshot == before)
// And the landing still happened the bookkeeping the skip must never cover.
#expect(store.landedReloads == 1)
#expect(store.reloadFailure == nil)
}
@Test("A single-file echo re-parses exactly that file, and does assign")
func oneEditedFileIsTheOnlyOneReParsed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Renamed"))
let counts = await reload(store)
#expect(counts == .init(parsed: 1, reused: Self.indexCount - 1))
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Renamed", "Second"])
#expect(store.snapshotGeneration == 1)
#expect(store.landedReloads == 1)
}
@Test("The memo carries from reload to reload rather than going cold every other walk")
func theMemoChains() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Three landings: one value-equal (which skips the assignment), one that changes a file, and
// one value-equal again. If the skipped landing dropped its memo, the walk after it would go
// cold which is exactly the bug this pins.
#expect(await reload(store) == .init(parsed: 0, reused: Self.indexCount))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Edited"))
#expect(await reload(store) == .init(parsed: 1, reused: Self.indexCount - 1))
#expect(await reload(store) == .init(parsed: 0, reused: Self.indexCount))
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Edited"])
#expect(store.snapshotGeneration == 1, "only the middle reload had anything to assign")
#expect(store.landedReloads == 3)
#expect(store.parseMemo.count == Self.indexCount)
}
@Test("A failed reload keeps the snapshot and bumps neither counter")
func aFailedReloadBumpsNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = store.snapshot
try fixture.item(Ident.lane1, brokenIndex)
_ = await reload(store)
#expect(store.snapshot == before, "a failed reload never replaces a good snapshot")
#expect(store.snapshotGeneration == 0)
#expect(store.landedReloads == 0, "there is no snapshot in hand, so nothing was covered")
#expect(store.reloadFailure?.primary.path == indexPath(Ident.lane1))
}
@Test("A failed reload's breakage clears on the next success, which is value-equal")
func breakageClearsOnAValueEqualSuccess() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
try fixture.item(Ident.lane1, brokenIndex)
_ = await reload(store)
#expect(store.reloadFailure != nil)
// Repaired to exactly what it was: the model comes back value-equal, the assignment is
// skipped and the standing breakage condition must still heal, because it is not board
// structure and is not what the skip covers.
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
_ = await reload(store)
#expect(store.reloadFailure == nil)
#expect(store.snapshotGeneration == 0, "the repaired tree is the tree that was already on screen")
#expect(store.landedReloads == 1)
}
// MARK: What the skip must never cover
@Test("Warnings follow the tree even when the model does not move")
func warningsFollowTheTree() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// A second non-UUID-shaped folder: a stray is not in the model, so this changes `loadWarnings`
// and nothing else. It is also never descended into, so the walk still opens no file.
try fixture.item("scratch", "not a board item at all\n")
let counts = await reload(store)
#expect(counts == .init(parsed: 0, reused: Self.indexCount))
#expect(store.snapshotGeneration == 0)
#expect(store.landedReloads == 1)
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "scratch")))
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")))
}
@Test("Defects follow the tree even when the model does not move")
func defectsFollowTheTree() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.looseCardFiles.isEmpty)
// A loose file beside an untouched `index.md`: pending work the walk found, with the snapshot
// value-equal on either side of it. Defects are what `runScheduledHeals` reads, so a skip
// that swallowed them would silently retire the heal engine on quiet boards.
try "loose".write(
to: fixture.url("\(Ident.lane1)/\(Ident.card1)").appendingPathComponent("notes.txt"),
atomically: true,
encoding: .utf8
)
_ = await reload(store)
#expect(store.snapshotGeneration == 0)
#expect(store.landedReloads == 1)
#expect(store.looseCardFiles.map(\.fileNames) == [["notes.txt"]])
}
@Test("An attachment arriving moves the snapshot, because the snapshot carries the listing")
func attachmentsAreNotMemoized() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let attachments = fixture.url("\(Ident.lane1)/\(Ident.card1)")
.appendingPathComponent("attachments", isDirectory: true)
try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
try "png".write(to: attachments.appendingPathComponent("shot.png"), atomically: true, encoding: .utf8)
let counts = await reload(store)
// Not one file opened, and the card's paperclip is still current: directory enumeration is
// outside the memo's scope by design, because an attachment never touches `index.md`.
#expect(counts == .init(parsed: 0, reused: Self.indexCount))
#expect(lane(Ident.lane1, in: store.snapshot)?.cards.first?.attachments == ["shot.png"])
#expect(store.snapshotGeneration == 1)
#expect(store.landedReloads == 1)
}
@Test("A no-change reload announces nothing and leaves no state behind")
func aNoChangeReloadIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
var spoken: [String?] = []
store.announce = { spoken.append($0) }
_ = await reload(store)
_ = await reload(store, .reconciling)
#expect(spoken == [nil, nil])
#expect(store.readOnlyLock == nil)
#expect(store.snapshotGeneration == 0)
#expect(store.landedReloads == 2)
}
@Test("A value-equal reload still lands for the auto-commit seam and the covering gate")
func aValueEqualReloadStillLands() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let landings = LandingCount()
store.commitSeam = HistoryCommitSeam(
willWrite: {},
writeBracketDidClose: {},
reloadDidLand: { _ in landings.value += 1 }
)
_ = await reload(store)
// The covering gate counts landings, not assignments (`GitAutoCommitter.landedReloads`), and
// the commit seam is armed by every landing whether or not the snapshot moved "a landing
// that finds nothing to commit is the silent no-op, not a wasted trip".
#expect(landings.value == 1)
#expect(store.landedReloads == 1)
#expect(store.snapshotGeneration == 0)
}
}
/// What the reload path told the history seam a box, because `HistoryCommitSeam` is a struct of
/// closures and a captured `var` cannot be read back after the reload has landed.
@MainActor
private final class LandingCount {
var value = 0
}
+105
View File
@@ -784,3 +784,108 @@ struct FixtureSkippableDefectsTests {
#expect(text.contains("labels: [red, green"), "the skipped file was rewritten") #expect(text.contains("labels: [red, green"), "the skipped file was rewritten")
} }
} }
// MARK: - Memo-vs-cold equivalence, over every golden board
/// **Result-purity with cost unspecified** (02-architecture.md § Live-reload resilience, blessed
/// 2026-07-31: "The loader's contract is result-purity with cost unspecified: same tree in, same
/// snapshot out, and the memo can only change how fast").
///
/// The memo is the one thing in the loader that could make two walks of the same tree disagree, so
/// the claim is stated where the trees are real and hand-authored: every golden board is walked cold,
/// then walked again with the first walk's memo, and the two results are compared whole. Nothing here
/// edits a tree the mechanism's per-file behaviour is `BoardLoaderParseMemoTests`' subject; this is
/// the equivalence, across every shape the fixture corpus holds.
///
/// The malformed boards are included on purpose. A refusal is a result too, and a memo that changed
/// which defects a walk collected or their order would be the worst possible way for this to be
/// wrong, since the decision surface is written directly against that list.
struct FixtureMemoEquivalenceTests {
/// Every board under `Fixtures/Valid`, by relative path.
static let validBoards = [
"Valid/rich-board.kanban",
"Valid/interrupted-create.kanban",
"Valid/non-uuid-strays.kanban",
"Valid/stray-files.kanban",
"Valid/tombstones.kanban",
"Valid/duplicate-order-tie-break.kanban",
"Valid/unknown-key-order.kanban",
"Valid/coercion.kanban",
"Valid/duplicate-top-level-keys.kanban",
"Valid/board-level-deleted.kanban",
"Valid/optional-keys.kanban",
]
/// Every board under `Fixtures/Malformed`, by relative path.
static let malformedBoards = [
"Malformed/board-root-missing-index.kanban",
"Malformed/missing-schema.kanban",
"Malformed/unparseable-yaml.kanban",
"Malformed/schema-newer-than-app.kanban",
"Malformed/many-defects.kanban",
"Malformed/skippable-defects.kanban",
]
@Test("A memoized walk of a valid board is the cold walk, whole", arguments: validBoards)
func aMemoizedWalkMatchesTheColdWalk(board: String) throws {
let cold = try loadFixture(board)
let warm = try BoardLoader.load(boardRoot: fixtureBoard(board), memo: cold.memo)
#expect(warm.model == cold.model)
#expect(warm.warnings == cold.warnings)
#expect(warm.defects == cold.defects)
#expect(warm.trashKinds == cold.trashKinds)
// The memo the second walk produced can stand in for the first's, which is what makes the
// store's chain of reloads self-sustaining rather than degrading walk by walk.
#expect(warm.memo.count == cold.memo.count)
}
@Test("A memoized walk of a valid board opens no index.md at all", arguments: validBoards)
func aMemoizedWalkReadsNothing(board: String) throws {
let cold = try loadFixture(board)
let counter = BoardLoader.ParseCounter()
_ = try BoardLoader.load(boardRoot: fixtureBoard(board), memo: cold.memo, counter: counter)
#expect(counter.counts.parsed == 0)
#expect(counter.counts.reused == cold.memo.count)
#expect(counter.counts.reused > 0, "a fixture with nothing to memoize proves nothing")
}
@Test("A memoized walk of a malformed board refuses identically", arguments: malformedBoards)
func aMemoizedWalkRefusesIdentically(board: String) {
// A refusing walk hands back no memo that is the design, not an omission: a defective file
// is never recorded, and a walk that threw produced no `LoadResult` to carry one on. So the
// memo under test is the empty one a first walk would offer, and what is pinned is that the
// memo parameter never moves the aggregate a refusal reports.
let defects = refusal(board, memo: nil)
#expect(defects == refusal(board, memo: BoardLoader.ParseMemo()))
#expect(!defects.isEmpty, "\(board) is in the malformed corpus but loaded")
}
/// One malformed board's aggregate, or `[]` where it unexpectedly loaded.
private func refusal(_ board: String, memo: BoardLoader.ParseMemo?) -> [BoardLoadError] {
do throws(BoardLoadFailure) {
_ = try BoardLoader.load(boardRoot: fixtureBoard(board), memo: memo)
return []
} catch {
return error.defects
}
}
/// The malformed corpus' real memo case: a board that refuses is repaired-by-skip into one that
/// loads, and the memo that produced carries into the next walk without moving the answer.
@Test("A skipped-open board reloads through its own memo unchanged")
func aSkippedOpenReloadsUnchanged() throws {
let skips: Set<String> = [SkippableDefects.newerCardPath, SkippableDefects.brokenLanePath]
let root = fixtureBoard(SkippableDefects.board)
let cold = try BoardLoader.load(boardRoot: root, skipping: skips)
let warm = try BoardLoader.load(boardRoot: root, skipping: skips, memo: cold.memo)
#expect(warm.model == cold.model)
#expect(warm.warnings == cold.warnings)
#expect(warm.defects == cold.defects)
}
}