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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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