Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -15,6 +15,12 @@ import Testing
|
||||
/// lock, and never hot-looping on a failure.
|
||||
/// 4. **A paste normalizes at the boundary** — the pasted card lands already tidy.
|
||||
///
|
||||
/// Since 2026-07-31 there is a gate in front of all four: **the board-root `.gitignore` is the noise
|
||||
/// definition**, and a loose file matching it is not work at all (§ Rules — "skipped, preserved
|
||||
/// verbatim, logged, never relocated, never announced"). That is section 1b, between the detection
|
||||
/// and the write, because it is a property of what counts as a defect rather than of what is done
|
||||
/// about one.
|
||||
///
|
||||
/// Like every other write suite here these read back through the loader or through raw bytes, never
|
||||
/// through a snapshot the store handed out: the claims are about the files. `WriterFixture`, `Ident`
|
||||
/// and `Item` come from `WriterTestSupport.swift`; `FakePasteboard` and `ClipboardHarness` from
|
||||
@@ -24,15 +30,17 @@ import Testing
|
||||
|
||||
/// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`.
|
||||
///
|
||||
/// It carries a **current agent guide**, which is what any board the app has opened once looks like
|
||||
/// (08-agent-integration.md ▸ The agent guide). Without it the store's own guide refresh — which
|
||||
/// runs on every successful reload, beside this file's relocation — would write a `CLAUDE.md` on
|
||||
/// the first reload and open a bracket of its own, and the bracket counts below would stop being
|
||||
/// claims about the relocation.
|
||||
private func makeCardBoard() throws -> WriterFixture {
|
||||
/// It carries a **current agent guide** and the **seeded `.gitignore`**, which is what any board the
|
||||
/// app has opened once looks like (08-agent-integration.md ▸ The agent guide; 06-history-undo.md ▸
|
||||
/// Repository hygiene). Without them the store's own scheduled heals — the guide refresh and the
|
||||
/// seed, which run on every successful reload beside this file's relocation — would write those two
|
||||
/// files on the first reload and open brackets of their own, and the bracket counts below would stop
|
||||
/// being claims about the relocation.
|
||||
private func makeCardBoard(gitignore: String = BoardWriter.gitignoreSeed) throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
|
||||
try fixture.file(IntegrityRules.gitignoreFileName, Data(gitignore.utf8))
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
||||
return fixture
|
||||
@@ -221,6 +229,169 @@ struct LooseFileDetectionTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 1b. The noise gate (the board-root .gitignore)
|
||||
|
||||
/// **`.gitignore` is the noise gate** (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-31): "a file matching the board-root `.gitignore` — standard gitignore pattern semantics
|
||||
/// against the board-relative path; nested `.gitignore` files are ordinary strays the heal never
|
||||
/// consults — keeps the ordinary stray posture: skipped, preserved verbatim, logged, never
|
||||
/// relocated, never announced. The exclusion list is exactly that file, nothing hardcoded".
|
||||
///
|
||||
/// The pattern *semantics* are `GitignoreRulesTests`'; what this suite pins is the wiring — which
|
||||
/// file is read, which path it is matched against, and that a match makes the file a stray rather
|
||||
/// than work.
|
||||
@Suite("Loose files ▸ the .gitignore noise gate")
|
||||
struct LooseFileNoiseGateTests {
|
||||
|
||||
@Test("A file matching the board's .gitignore is not a loose file")
|
||||
func matchedFileIsNotWork() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "*.tmp\n")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
|
||||
|
||||
#expect(try looseFiles(in: fixture).isEmpty)
|
||||
}
|
||||
|
||||
/// The seed's own first line, doing its job on the file it was written for. `.DS_Store` is also
|
||||
/// hidden — and hidden entries are excluded for a structural reason of their own (a relocated
|
||||
/// hidden file would land where `attachmentNames` can never list it) — so this is the belt and
|
||||
/// the braces, which is what the ruling asks for: the seed is why it is *noise*.
|
||||
@Test("A .DS_Store beside a card's index.md is neither relocated nor announced")
|
||||
@MainActor
|
||||
func finderLitterIsSilent() throws {
|
||||
let fixture = try makeCardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
#expect(fixture.exists("\(cardPath)/.DS_Store"), "preserved verbatim, exactly where it was")
|
||||
#expect(!fixture.exists("\(cardPath)/attachments"))
|
||||
#expect(store.banners.losses.isEmpty, "never announced")
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A matched custom pattern is skipped, preserved and never announced")
|
||||
@MainActor
|
||||
func matchedCustomPatternIsSilent() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "*.tmp\nbuild/\n")
|
||||
defer { fixture.tearDown() }
|
||||
let noise = try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
|
||||
let before = try stat(noise)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
let after = try stat(noise)
|
||||
#expect(after.bytes == before.bytes)
|
||||
#expect(after.modified == before.modified, "not opened, not moved, not touched")
|
||||
#expect(!fixture.exists("\(cardPath)/attachments"))
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An unmatched loose file still relocates, with its notice")
|
||||
@MainActor
|
||||
func unmatchedFileStillRelocates() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "*.tmp\n")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||
#expect(fixture.exists("\(cardPath)/scratch.tmp"))
|
||||
// One file moved, so one file is named — the noise is not in the sentence either.
|
||||
#expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"])
|
||||
}
|
||||
|
||||
/// 06's escape hatch, working: "the escape hatch for wanting no exclusions is an *empty* file".
|
||||
@Test("An empty .gitignore excludes nothing — everything loose relocates")
|
||||
@MainActor
|
||||
func emptyFileExcludesNothing() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
#expect(try fixture.entryNames("\(cardPath)/attachments") == ["notes.txt", "scratch.tmp"])
|
||||
#expect(store.banners.losses.map(\.message) == ["Moved 2 files into attachments — 'Fix login'"])
|
||||
}
|
||||
|
||||
/// A board that has never been opened by this version has no gate at all, and that reads as "no
|
||||
/// exclusions" — the pre-ruling behaviour, and the same answer the empty file gives.
|
||||
@Test("A board with no .gitignore excludes nothing")
|
||||
func noFileExcludesNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
|
||||
#expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]])
|
||||
}
|
||||
|
||||
/// **The board-relative path is what patterns match**, which is what makes an anchored pattern
|
||||
/// mean the board root rather than every card in the board.
|
||||
@Test("Patterns match the board-relative path, so anchoring means the board root")
|
||||
func anchoredPatternsMeanTheBoardRoot() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "/notes.txt\n")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
|
||||
// The pattern is about `<root>/notes.txt`; the card's file is a different path entirely.
|
||||
#expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]])
|
||||
|
||||
// The same file, named the way the card actually sits on disk, is excluded.
|
||||
let anchored = try makeCardBoard(gitignore: "/\(cardPath)/notes.txt\n")
|
||||
defer { anchored.tearDown() }
|
||||
try anchored.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
#expect(try looseFiles(in: anchored).isEmpty)
|
||||
}
|
||||
|
||||
/// "Nested `.gitignore` files are ordinary strays the heal never consults." One inside a card is
|
||||
/// itself a hidden entry, so it is not even relocatable; one inside a *lane* is a lane-level
|
||||
/// stray, and neither has any say over the card beside it.
|
||||
@Test("Nested .gitignore files are never consulted")
|
||||
func nestedFilesAreOrdinaryStrays() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(Ident.lane1)/\(IntegrityRules.gitignoreFileName)", Data("*.txt\n".utf8))
|
||||
try fixture.file("\(cardPath)/\(IntegrityRules.gitignoreFileName)", Data("*.txt\n".utf8))
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
|
||||
#expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]])
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(IntegrityRules.gitignoreFileName)"))
|
||||
#expect(fixture.exists("\(cardPath)/\(IntegrityRules.gitignoreFileName)"))
|
||||
}
|
||||
|
||||
/// The import boundary reads the same file: a paste that swept a file the very next walk would
|
||||
/// have left alone would be the app disagreeing with itself one gesture apart.
|
||||
@Test("The import-boundary normalization obeys the same gate")
|
||||
func normalizationObeysTheGate() throws {
|
||||
let fixture = try makeCardBoard(gitignore: "*.tmp\n")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
|
||||
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
|
||||
|
||||
let moved = try BoardWriter.normalizeLooseFiles(inCard: cardFolder(in: fixture))
|
||||
|
||||
#expect(moved.map(\.fileName) == ["notes.txt"])
|
||||
#expect(fixture.exists("\(cardPath)/scratch.tmp"))
|
||||
|
||||
// …and the lane-level face, which reads the file once for the whole lane.
|
||||
try fixture.file("\(cardPath)/second.tmp", Data("more noise".utf8))
|
||||
#expect(try BoardWriter.normalizeLooseFiles(inLane: fixture.url(Ident.lane1)).isEmpty)
|
||||
#expect(fixture.exists("\(cardPath)/second.tmp"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 2. The write (BoardWriter)
|
||||
|
||||
@Suite("Loose files ▸ the relocation write")
|
||||
|
||||
Reference in New Issue
Block a user