Relocate loose card files into attachments

01's Lanework-owns-the-board carve-out: a regular file beside a card's
index.md belongs in attachments/, and the app moves it there. The
loader detects read-only — a new LoadResult.looseCardFiles channel,
separate from the stray-tolerance warnings because it says the opposite
thing — skipping directories, symlinks, hidden entries, and the
reserved names compared case-insensitively (on APFS, Index.md IS the
index). The relocation rides one performWrite bracket at the tail of
every successful reload, which makes lock deferral free: the reload
that lifts a read-only lock is the reload that relocates. A
lane/card/filename memo keeps a failing relocation from hot-looping —
one one-shot, then silence until disk changes. The notice rides the
loss-row class, phrasing folded by BannerCenter (one file, one card's
files, a multi-card sweep), naming original filenames per the
importAttachment rule. Paste normalizes at the import boundary: staged
snapshots' loose files land in the pasted card's attachments silently,
every arrival path declaring its side via an explicit
normalizingLooseFiles parameter — drag paths decline and fall back to
the destination's own carve-out. checkIsCardFolder closes the hole
where a lane's notes.txt would have been relocated: card depth is
exact, UUID under UUID.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 09:09:31 -04:00
parent 5c0c0e5619
commit af1860debf
10 changed files with 1434 additions and 22 deletions
+33
View File
@@ -223,6 +223,39 @@ struct FixtureStrayFilesTests {
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
}
/// The board's card-level `scratch.md` is the **one** stray this fixture holds that is not
/// tolerated: the loose-file carve-out (01-storage-format.md § Fractal layout Rules, settled
/// 2026-07-28) says a regular file beside a card's `index.md` belongs in `attachments/`. It is
/// reported on its own channel never as a `warning`, which is the *tolerance* vocabulary
/// and the board-level and lane-level strays around it stay exactly as tolerated as they were.
///
/// **Detection does not mutate**: this is the loader, over a fixture that lives in git, and the
/// assertion that the file is still there afterwards is the read-only claim stated on the one
/// tree where a stray write would be visible in `git status`.
@Test func aCardLevelLooseFileIsReportedForRelocationWithoutBeingTouched() throws {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
let result = try loadFixture("Valid/stray-files.kanban")
#expect(result.warnings.isEmpty)
#expect(result.looseCardFiles == [
LooseCardFiles(
laneID: ItemID(rawValue: lane),
cardID: ItemID(rawValue: card),
title: result.model.lanes[0].cards[0].title.value,
fileNames: ["scratch.md"]
),
])
let scratch = fixtureBoard("Valid/stray-files.kanban")
.appendingPathComponent("\(lane)/\(card)/scratch.md")
#expect(FileManager.default.fileExists(atPath: scratch.path))
#expect(!FileManager.default.fileExists(
atPath: fixtureBoard("Valid/stray-files.kanban")
.appendingPathComponent("\(lane)/\(card)/attachments").path
))
}
}
// MARK: - Valid/tombstones.kanban