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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,53 +921,92 @@ 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
|
||||||
withAnimation(Motion.reloadAnimation(
|
// *tree*, not the model, and can move while the model does not: a new stray folder, a
|
||||||
origin: origin,
|
// loose file appearing beside an untouched `index.md`), the breakage clearing, the
|
||||||
endsBracketedOperation: endsWholesaleOperation,
|
// comment index, the lock reconciliation, the display write-through, the heals, the
|
||||||
reduced: Motion.prefersReducedMotion
|
// announcement and the commit seam — happens below on every landing, equal or not.
|
||||||
)) {
|
//
|
||||||
snapshot = result.model
|
// What the guard covers is precisely what an equal model makes vacuous: `transient
|
||||||
snapshotGeneration += 1
|
// .resolve(against:)` re-grounds state against a tree that did not move, and
|
||||||
loadWarnings = result.warnings
|
// `recoverFocus` is handed a `.survived` outcome by construction (`focusOutcome` over two
|
||||||
// The one place transient state is re-grounded. It goes last, after `snapshot` is
|
// equal models can find nothing vanished), so skipping them changes no state a caller
|
||||||
// the new one, because a view woken by the snapshot's change must never observe a
|
// could observe — only the render pass they would have cost.
|
||||||
// selection still pointing at the old tree.
|
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.
|
||||||
//
|
//
|
||||||
// Inside the transaction deliberately: 03 § Motion has the selection highlight
|
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare
|
||||||
// "ride whatever transaction is active rather than easing on its own", and a
|
// assignment, which is what the snapping origins and the Reduce Motion variant both
|
||||||
// re-grounding that landed outside this one would be exactly the independent ease
|
// want.
|
||||||
// that rules out.
|
withAnimation(Motion.reloadAnimation(
|
||||||
//
|
origin: origin,
|
||||||
// The comment index' current answer rides along, because the filter half of the
|
endsBracketedOperation: endsWholesaleOperation,
|
||||||
// re-grounding is the *whole* filter (04 ▸ Search, re-ruled 2026-07-29) — a card
|
reduced: Motion.prefersReducedMotion
|
||||||
// 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
|
snapshot = result.model
|
||||||
// the transaction, and its landing re-runs this constraint through `onRefine`.
|
snapshotGeneration += 1
|
||||||
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
|
// The one place transient state is re-grounded. It goes last, after `snapshot` is
|
||||||
// And *then* the recovery, on top of the set rule rather than instead of it: the
|
// the new one, because a view woken by the snapshot's change must never observe a
|
||||||
// resolution leaves an emptied selection wherever the focused item used to be, and
|
// selection still pointing at the old tree.
|
||||||
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
|
//
|
||||||
// lane … walks up then sideways"). Inside the same transaction for the highlight's
|
// Inside the transaction deliberately: 03 § Motion has the selection highlight
|
||||||
// sake, exactly like the resolution it follows.
|
// "ride whatever transaction is active rather than easing on its own", and a
|
||||||
recoverFocus(focus.recovery)
|
// re-grounding that landed outside this one would be exactly the independent ease
|
||||||
|
// that rules out.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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 the transaction, and its landing re-runs this constraint through
|
||||||
|
// `onRefine`.
|
||||||
|
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
|
||||||
|
// 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 this is 10-accessibility.md's answer to the hole ("focus recovers to the
|
||||||
|
// card's lane … walks up then sideways"). Inside the same transaction for the
|
||||||
|
// highlight's sake, exactly like the resolution it follows.
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user