diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 16c0045..24d3fd6 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -889,8 +889,13 @@ public final class AppModel { // 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 // 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.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 // 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 diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 01dfd7c..ffecf6f 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -484,13 +484,19 @@ struct CardWindowHost: View { attachments.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"). // // *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 - // not vend the changed paths, and comments are outside the snapshot entirely - // (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here. + // store's observable surface publishes counters and a `BoardModel` — it does not vend the + // changed paths, and comments are outside the snapshot entirely (01-storage-format.md + // § 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 // 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 @@ -499,7 +505,7 @@ struct CardWindowHost: View { // ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from // its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()` // 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() } } else { diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index 0a47423..a8d1af8 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -142,14 +142,21 @@ public final class GitAutoCommitter { @ObservationIgnored public var awaitReloadQuiescence: (@MainActor () async -> Void)? - /// **Which generation the board `currentSnapshot` answers with is at** — - /// `BoardStore.snapshotGeneration`, incremented by every landed reload. + /// **How many tree walks the board has landed** — `BoardStore.landedReloads`, incremented by + /// 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 /// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless /// committer. @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 /// 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 /// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite. private func awaitCoveringSnapshot() async { - guard holdsUncoveredWrites, let read = snapshotGeneration else { return } + guard holdsUncoveredWrites, let read = landedReloads else { return } await awaitReloadQuiescence?() // Re-read the gate: the quiescence may itself have been the covering landing. guard holdsUncoveredWrites, let base = read() else { return } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 588d6ad..204a490 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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. 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): - /// a drop's overlay stands until "the next snapshot application on that store", and *application* - /// is the event, not change. A reload that produced an identical model still ends the round trip - /// the overlay was covering — comparing `snapshot` values would leave the overlay standing - /// exactly when the write turned out to be a no-op. + /// a drop's overlay stands until the next snapshot application on that store, and the whole point + /// of the counter is that it is an *event* the overlay can watch rather than a value it would have + /// to diff. + /// + /// **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 + /// 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 /// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always /// describe the tree currently on screen. @@ -488,6 +520,41 @@ public final class BoardStore: HealHost { @ObservationIgnored 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 /// **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 @@ -646,6 +713,8 @@ public final class BoardStore: HealHost { self.snapshot = result.model self.loadWarnings = result.warnings 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.reloadFailure = 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 // decision stands for the session, so a reload sees the board the user chose to open. 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 Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))") @@ -729,7 +804,12 @@ public final class BoardStore: HealHost { let outcome: Result do throws(BoardLoadFailure) { outcome = .success(try BoardLoader.load( - boardRoot: root, skipping: skipping, historyRanker: historyRanker)) + boardRoot: root, + skipping: skipping, + historyRanker: historyRanker, + memo: memo, + counter: counter + )) } catch { outcome = .failure(error) } @@ -841,53 +921,92 @@ public final class BoardStore: HealHost { } facts.vanishedFocus = focus.vanished - // **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. + // **The memo rides the reload, not the snapshot** (`parseMemo`): bookkeeping, so it is + // taken from every landing — the value-equal ones most of all, since those are exactly + // the walks whose stamps are freshest and whose successor would otherwise go cold. + parseMemo = result.memo + + // **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, - // which is what the snapping origins and the Reduce Motion variant both want. - withAnimation(Motion.reloadAnimation( - origin: origin, - endsBracketedOperation: endsWholesaleOperation, - reduced: Motion.prefersReducedMotion - )) { - snapshot = result.model - snapshotGeneration += 1 - loadWarnings = result.warnings - // The one place transient state is re-grounded. It goes last, after `snapshot` is - // the new one, because a view woken by the snapshot's change must never observe a - // selection still pointing at the old tree. + // It is a skip of the **observable assignment and nothing else**. Everything a landing + // owes — the memo above, the landing count, the warnings and defects (which describe the + // *tree*, not the model, and can move while the model does not: a new stray folder, a + // loose file appearing beside an untouched `index.md`), the breakage clearing, the + // comment index, the lock reconciliation, the display write-through, the heals, the + // announcement and the commit seam — happens below on every landing, equal or not. + // + // What the guard covers is precisely what an equal model makes vacuous: `transient + // .resolve(against:)` re-grounds state against a tree that did not move, and + // `recoverFocus` is handed a `.survived` outcome by construction (`focusOutcome` over two + // equal models can find nothing vanished), so skipping them changes no state a caller + // could observe — only the render pass they would have cost. + if result.model != snapshot { + // **The motion language's one decision point** (03-board-ui.md § Motion, via + // `Motion`). "User-initiated structural changes animate; foreign changes snap" cannot + // live at the call sites here the way it did in the pathfinder — the one-way flow + // means the user's own delete arrives back through the watcher exactly like an + // agent's edit — so it lives at the *reload*, whose origin is already classified. + // `Motion` owns which origins perform and in which voice; this store owns only the + // assignment they wrap. // - // Inside the transaction deliberately: 03 § Motion has the selection highlight - // "ride whatever transaction is active rather than easing on its own", and a - // 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) + // A `nil` animation is not a special case: `withAnimation(nil)` is the bare + // assignment, which is what the snapping origins and the Reduce Motion variant both + // want. + withAnimation(Motion.reloadAnimation( + origin: origin, + endsBracketedOperation: endsWholesaleOperation, + reduced: Motion.prefersReducedMotion + )) { + snapshot = result.model + snapshotGeneration += 1 + // The one place transient state is re-grounded. It goes last, after `snapshot` is + // the new one, because a view woken by the snapshot's change must never observe a + // selection still pointing at the old tree. + // + // Inside the transaction deliberately: 03 § Motion has the selection highlight + // "ride whatever transaction is active rather than easing on its own", and a + // 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 // 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 // this one did not. - reloadFailure = nil - defects = result.defects + if loadWarnings != result.warnings { loadWarnings = result.warnings } + if reloadFailure != nil { reloadFailure = nil } + if defects != result.defects { defects = result.defects } // **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 // 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 } 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( query: transient.searchQuery, - generation: snapshotGeneration, + generation: landedReloads, targets: CommentSearchIndex.targets(in: snapshot) ) } diff --git a/Kanban/LiveStore/CommentSearchIndex.swift b/Kanban/LiveStore/CommentSearchIndex.swift index 94d21df..d99dd9e 100644 --- a/Kanban/LiveStore/CommentSearchIndex.swift +++ b/Kanban/LiveStore/CommentSearchIndex.swift @@ -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 /// 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 -/// 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 /// 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 /// exists, this is the accepted interim and is written down here rather than discovered later. /// diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 7120255..02f1bce 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization import os /// 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) } + // 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 /// 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 /// `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( boardRoot: URL, skipping: Set = [], - historyRanker: IdentityHistoryRanker? = nil + historyRanker: IdentityHistoryRanker? = nil, + memo: ParseMemo? = nil, + counter: ParseCounter? = nil ) throws(BoardLoadFailure) -> LoadResult { // Environmental, so immediate: a root that cannot be listed has no walk to collect from. do throws(BoardLoadError) { @@ -253,6 +408,11 @@ public enum BoardLoader: Sendable { // what `BoardLoadFailure` carries when it does not. 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 /// exact path. /// @@ -277,12 +437,16 @@ public enum BoardLoader: Sendable { let boardIndexURL = boardRoot.appendingPathComponent(indexFileName) if FileManager.default.fileExists(atPath: boardIndexURL.path) { 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, // 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. - boardSchema = try validatedRootSchema(in: document, path: indexFileName) - boardDocument = document + boardSchema = try validatedRootSchema(in: read.document, path: indexFileName) + 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 { record(error) } @@ -386,12 +550,19 @@ public enum BoardLoader: Sendable { let lanePath = laneName + "/" + indexFileName let laneDocument: FrontmatterDocument let laneSchema: (schema: Int, coerced: CoercedField?) + let laneStamp: FileStamp? // **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 // what surfaces whatever they were hiding. do throws(BoardLoadError) { - laneDocument = try readDocument( - at: laneURL.appendingPathComponent(indexFileName), path: lanePath) + let read = try memoizedDocument( + 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` // 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 @@ -401,6 +572,7 @@ public enum BoardLoader: Sendable { record(error) continue } + freshMemo.record(laneDocument, at: lanePath, stamp: laneStamp) let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument) noteCoercions( in: laneDocument, @@ -423,11 +595,14 @@ public enum BoardLoader: Sendable { let card: WalkedCard do throws(BoardLoadError) { - card = try parseCard(at: cardURL, path: cardRelPath) + card = try parseCard(at: cardURL, path: cardRelPath, memo: memo, counter: counter) } catch { record(error) 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) // **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 document: FrontmatterDocument 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 // 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). do throws(BoardLoadError) { - document = try readDocument( - at: entryURL.appendingPathComponent(indexFileName), path: entryPath) + let read = try memoizedDocument( + at: entryURL.appendingPathComponent(indexFileName), + path: entryPath, + memo: memo, + counter: counter + ) + document = read.document + stamp = read.stamp schema = try resolvedSchema(in: document, path: entryPath) } catch { record(error) continue } + freshMemo.record(document, at: entryPath, stamp: stamp) let order = IntegrityRules.resolvedOrder(in: document) noteCoercions( in: document, @@ -727,6 +910,7 @@ public enum BoardLoader: Sendable { return LoadResult( model: model, warnings: warnings, + memo: freshMemo, defects: defects, // 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 @@ -929,6 +1113,9 @@ public enum BoardLoader: Sendable { /// 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`). 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 { document.title } 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`. /// 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 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 order = IntegrityRules.resolvedOrder(in: document) @@ -973,7 +1176,8 @@ public enum BoardLoader: Sendable { storedOrder: order.order, attachments: attachmentNames(in: cardURL), 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 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* /// (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 diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index a9e1f31..cce6d1d 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -162,6 +162,13 @@ struct FileDropTarget: Equatable, Sendable { /// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything /// 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 /// 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. diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index 70bdc19..fc44479 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -1245,7 +1245,7 @@ struct AutoCommitMessageTests { var generation = 0 var reads = 0 committer.awaitReloadQuiescence = {} - committer.snapshotGeneration = { + committer.landedReloads = { reads += 1 if reads == landsAfterReads { current = (try? fixture.snapshot()) ?? current diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index edb90b2..3b2a1d9 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -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 = ["\(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 = ["\(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"]) + } +} diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 04a0a6d..870f263 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -602,3 +602,233 @@ struct BoardStoreTests { #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 +} diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index 4f90684..dc62a87 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -784,3 +784,108 @@ struct FixtureSkippableDefectsTests { #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 = [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) + } +}