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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user