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 by 16ef377:

- 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:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+342
View File
@@ -0,0 +1,342 @@
import Foundation
import Testing
@testable import Kanban
/// **The board's noise definition, as a parser** (01-storage-format.md § Fractal layout Rules,
/// ruled 2026-07-31: "`.gitignore` is the noise gate"; 06-history-undo.md Repository hygiene).
///
/// `GitignoreRules` is pure text in, verdicts out so this suite is a table and nothing else: no
/// filesystem, no board, no store. What it pins is that the app's own matcher is **git's**, because
/// the ruling's whole premise is one definition of noise shared with the committer: "on Pro boards
/// the same file governs the committer, so ignored noise neither relocates nor commits". A matcher
/// that read the file differently from libgit2 would make that one sentence two behaviours.
///
/// The claims are grouped as `gitignore(5)` states them, in its order, plus the seed the app writes
/// and the three deliberate divergences the type documents.
/// One assertion, spelled the way the file reads: patterns on the left, a path on the right.
private func ignores(_ file: String, _ path: String, isDirectory: Bool = false) -> Bool {
GitignoreRules(parsing: file).isIgnored(relativePath: path, isDirectory: isDirectory)
}
// MARK: - What is a pattern at all
@Suite("Gitignore ▸ the line grammar")
struct GitignoreLineGrammarTests {
@Test("Blank lines and comments match nothing")
func blanksAndComments() {
let file = """
# a comment
#notes.txt
"""
let rules = GitignoreRules(parsing: file)
#expect(rules.isEmpty)
#expect(!rules.isIgnored(relativePath: "notes.txt"))
#expect(!rules.isIgnored(relativePath: "# a comment"))
}
/// An empty file is 06's own escape hatch "the escape hatch for wanting no exclusions is an
/// *empty* file, which the app honors" and it must read as "excludes nothing", never as
/// "excludes everything".
@Test("An empty file excludes nothing, and says so")
func emptyFile() {
#expect(GitignoreRules(parsing: "").isEmpty)
#expect(!ignores("", "notes.txt"))
#expect(!ignores("", "lane/card/notes.txt"))
#expect(!ignores("\n\n\n", "notes.txt"))
}
/// `#` only comments when it *begins* the line, and `\#` writes a pattern that starts with one.
@Test("A hash is escapable, and only leading hashes comment")
func escapedHash() {
#expect(ignores("\\#notes.txt", "#notes.txt"))
#expect(!ignores("\\#notes.txt", "notes.txt"))
#expect(ignores("notes#1.txt", "notes#1.txt"))
}
/// "Trailing spaces are ignored unless they are quoted with backslash."
@Test("Trailing spaces are dropped unless escaped")
func trailingSpaces() {
#expect(ignores("notes.txt ", "notes.txt"))
// The escaped one is part of the name so the bare name no longer matches, and the
// space-suffixed one does.
#expect(ignores("notes.txt\\ ", "notes.txt "))
#expect(!ignores("notes.txt\\ ", "notes.txt"))
// Only spaces, and only trailing: a tab is part of the pattern (git trims spaces alone).
#expect(ignores("notes.txt\t", "notes.txt\t"))
}
/// A `.gitignore` hand-edited on Windows must not become a file of patterns nobody can match
/// git terminates each pattern before the `\r`, and so does this.
@Test("CRLF line endings parse, and a BOM is skipped")
func lineEndingsAndBOM() {
#expect(ignores("*.tmp\r\nbuild/\r\n", "scratch.tmp"))
#expect(ignores("\u{FEFF}*.tmp\n", "scratch.tmp"))
}
}
// MARK: - Anchoring
@Suite("Gitignore ▸ anchoring")
struct GitignoreAnchoringTests {
/// "If there is no separator the pattern may also match at any level below" which is the
/// property the seed leans on entirely: one `.DS_Store` line covers every folder in the board.
@Test("A separator-less pattern matches at every depth")
func unanchoredMatchesEverywhere() {
let file = ".DS_Store\n"
#expect(ignores(file, ".DS_Store"))
#expect(ignores(file, "lane/.DS_Store"))
#expect(ignores(file, "lane/card/.DS_Store"))
#expect(!ignores(file, "lane/card/DS_Store"))
#expect(ignores(file, "lane/.DS_Store/inside.txt"), "and a directory by that name takes everything with it")
}
@Test("A leading slash anchors to the board root")
func leadingSlashAnchors() {
let file = "/notes.txt\n"
#expect(ignores(file, "notes.txt"))
#expect(!ignores(file, "lane/notes.txt"))
#expect(!ignores(file, "lane/card/notes.txt"))
}
/// "If there is a separator at the beginning or middle (or both) the pattern is relative to
/// the directory level of the particular `.gitignore` file itself" which for a board is its
/// root, and the reason the loose-file gate matches the **board-relative** path.
@Test("An interior slash anchors too")
func interiorSlashAnchors() {
let file = "lane/notes.txt\n"
#expect(ignores(file, "lane/notes.txt"))
#expect(!ignores(file, "other/lane/notes.txt"))
#expect(!ignores(file, "notes.txt"))
}
/// A *trailing* separator is the directory marker and does not anchor: `build/` still means "any
/// folder called build, anywhere".
@Test("A trailing slash does not anchor")
func trailingSlashDoesNotAnchor() {
let file = "build/\n"
#expect(ignores(file, "build", isDirectory: true))
#expect(ignores(file, "lane/card/build", isDirectory: true))
#expect(ignores(file, "lane/card/build/output.o"))
}
}
// MARK: - Directory-only patterns
@Suite("Gitignore ▸ directory-only patterns")
struct GitignoreDirectoryTests {
@Test("A trailing slash matches only directories")
func directoryOnly() {
let file = "cache/\n"
#expect(ignores(file, "cache", isDirectory: true))
#expect(!ignores(file, "cache", isDirectory: false), "a *file* called cache is not what the pattern is about")
// and everything inside the directory rides along.
#expect(ignores(file, "cache/thing.bin"))
}
@Test("Without the slash, files and directories both match")
func withoutTheSlash() {
#expect(ignores("cache\n", "cache", isDirectory: false))
#expect(ignores("cache\n", "cache", isDirectory: true))
}
}
// MARK: - Negation and last-match-wins
@Suite("Gitignore ▸ negation and precedence")
struct GitignoreNegationTests {
@Test("The last matching pattern decides")
func lastMatchWins() {
#expect(!ignores("*.txt\n!notes.txt\n", "notes.txt"))
#expect(ignores("!notes.txt\n*.txt\n", "notes.txt"), "order is the whole of the rule")
#expect(ignores("*.txt\n!notes.txt\n*.txt\n", "notes.txt"))
#expect(ignores("*.txt\n!notes.txt\n", "other.txt"))
}
/// "It is not possible to re-include a file if a parent directory of that file is excluded"
/// git never descends into an ignored directory, so the rule that would have rescued the file is
/// never read at all.
@Test("A negation cannot reach inside an excluded directory")
func negationCannotEscapeAnExcludedParent() {
let file = "build/\n!build/keep.txt\n"
#expect(ignores(file, "build/keep.txt"))
#expect(ignores(file, "build/deep/keep.txt"))
}
/// A directory the file re-includes is not excluded, so its contents are reachable again.
@Test("A re-included directory lets its contents through")
func reIncludedDirectory() {
let file = "lane\n!lane\n"
#expect(!ignores(file, "lane/card/notes.txt"))
}
@Test("A leading bang is escapable")
func escapedBang() {
#expect(ignores("\\!important.txt\n", "!important.txt"))
#expect(!ignores("\\!important.txt\n", "important.txt"))
}
}
// MARK: - Wildcards
@Suite("Gitignore ▸ wildcards")
struct GitignoreWildcardTests {
@Test("A star matches any run of characters but never a separator")
func starDoesNotCrossSeparators() {
#expect(ignores("*.tmp\n", "scratch.tmp"))
#expect(ignores("*.tmp\n", "lane/card/scratch.tmp"))
#expect(ignores("lane/*.tmp\n", "lane/scratch.tmp"))
#expect(!ignores("lane/*.tmp\n", "lane/card/scratch.tmp"), "one star, one segment")
#expect(ignores("*\n", "anything"))
}
@Test("A star matches nothing at all, at either end")
func starMatchesEmpty() {
#expect(ignores("*.tmp\n", ".tmp"))
#expect(ignores("notes*\n", "notes"))
#expect(ignores("*notes*\n", "notes"))
}
@Test("A question mark is exactly one character, and never a separator")
func questionMark() {
#expect(ignores("shot?.png\n", "shot1.png"))
#expect(!ignores("shot?.png\n", "shot.png"))
#expect(!ignores("shot?.png\n", "shot10.png"))
#expect(!ignores("a?b\n", "a/b"))
}
@Test("Character classes: sets, ranges, negation, and a literal bracket")
func characterClasses() {
#expect(ignores("shot[0-9].png\n", "shot7.png"))
#expect(!ignores("shot[0-9].png\n", "shotX.png"))
#expect(ignores("shot[abc].png\n", "shotb.png"))
#expect(!ignores("shot[!abc].png\n", "shotb.png"))
#expect(ignores("shot[!abc].png\n", "shotz.png"))
#expect(ignores("shot[^abc].png\n", "shotz.png"), "^ negates too")
// A `]` first in the group is a literal member, not the terminator.
#expect(ignores("weird[]].txt\n", "weird].txt"))
// An unterminated group is a literal bracket the reading that cannot lose a character.
#expect(ignores("draft[1.txt\n", "draft[1.txt"))
}
@Test("Escapes make a wildcard literal")
func escapedWildcards() {
#expect(ignores("star\\*.txt\n", "star*.txt"))
#expect(!ignores("star\\*.txt\n", "starry.txt"))
}
}
// MARK: - Globstar
@Suite("Gitignore ▸ ** segments")
struct GitignoreGlobstarTests {
@Test("A leading **/ matches at any depth")
func leadingGlobstar() {
let file = "**/notes.txt\n"
#expect(ignores(file, "notes.txt"))
#expect(ignores(file, "lane/notes.txt"))
#expect(ignores(file, "lane/card/notes.txt"))
}
/// "A trailing `/**` matches everything inside" everything *inside*, so the directory itself is
/// not what this pattern is about.
@Test("A trailing /** matches everything inside, not the folder itself")
func trailingGlobstar() {
let file = "lane/**\n"
#expect(ignores(file, "lane/card"))
#expect(ignores(file, "lane/card/notes.txt"))
#expect(!ignores(file, "lane", isDirectory: true))
}
/// "`a/**/b` matches `a/b`, `a/x/b`, `a/x/y/b`" zero or more segments, verbatim.
@Test("A middle /**/ spans zero or more directories")
func middleGlobstar() {
let file = "a/**/b\n"
#expect(ignores(file, "a/b"))
#expect(ignores(file, "a/x/b"))
#expect(ignores(file, "a/x/y/b"))
#expect(!ignores(file, "b"))
#expect(!ignores(file, "x/a/b"))
}
/// "Other consecutive asterisks are considered regular asterisks" inside a segment, `**` is
/// just `*`, so it still cannot cross a separator.
@Test("Asterisks inside a segment are ordinary stars")
func consecutiveStarsInsideASegment() {
#expect(ignores("a**b\n", "axxb"))
#expect(!ignores("a**b\n", "a/x/b"))
}
}
// MARK: - The seed
@Suite("Gitignore ▸ the seed the app writes")
struct GitignoreSeedSemanticsTests {
private let seed = GitignoreRules(parsing: BoardWriter.gitignoreSeed)
@Test("The seed is exactly the two lines the ruling names")
func theSeedText() {
// 06-history-undo.md Repository hygiene: "`.DS_Store` plus the writer's temp pattern
// (`.*.lanework-*`)". Files the app creates end with LF.
#expect(BoardWriter.gitignoreSeed == ".DS_Store\n.*.lanework-*\n")
}
@Test("It covers the Finder's litter at every level")
func finderLitter() {
#expect(seed.isIgnored(relativePath: ".DS_Store"))
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/.DS_Store"))
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/.DS_Store"))
}
/// The second line and `BoardWriter.atomicReplace`'s temp name are one fact spelled twice, so
/// this asserts against a name the Writer's own rule produces rather than a hand-written one.
@Test("It covers a crashed write's residue")
func writerTempResidue() {
let residue = ".index.md.lanework-\(UUID().uuidString)"
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/\(residue)"))
#expect(seed.isIgnored(relativePath: ".\(IntegrityRules.gitignoreFileName).lanework-\(UUID().uuidString)"))
}
@Test("And nothing else — a card's real files are not noise")
func nothingElse() {
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/notes.txt"))
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/index.md"))
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/DS_Store.txt"))
#expect(!seed.isIgnored(relativePath: "CLAUDE.md"))
}
}
// MARK: - The documented divergences
@Suite("Gitignore ▸ the deliberate divergences")
struct GitignoreDivergenceTests {
/// Matching is case-sensitive, always: `core.ignorecase` is a repository setting on a file this
/// app reads on boards that have no repository, and folding a *pattern* would silently widen
/// what the user wrote.
@Test("Patterns are case-sensitive")
func caseSensitive() {
#expect(ignores("*.tmp\n", "scratch.tmp"))
#expect(!ignores("*.tmp\n", "scratch.TMP"))
#expect(!ignores(".DS_Store\n", ".ds_store"))
}
/// POSIX bracket expressions are not a grammar this matcher has: `[[:digit:]]` reads as the
/// ordinary group `[` `[:digt]` the characters between the brackets followed by a literal
/// `]`, so it matches `shot:].png` rather than `shot7.png`. Nothing realistic in a board's noise
/// file writes one, and a second character-class grammar to hold them would be exactly the
/// over-engineering the type exists to avoid.
@Test("POSIX bracket expressions are read as ordinary classes")
func posixClasses() {
#expect(ignores("shot[[:digit:]].png\n", "shot:].png"))
#expect(!ignores("shot[[:digit:]].png\n", "shot7.png"))
}
}