Denial is not absence — detection learns the unverifiable answer 06 ruled for it
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -9,7 +9,9 @@ import Testing
|
||||
/// The rule has four claims and this file is one test per claim: root wins, an ancestor is nested,
|
||||
/// neither is `none`, and the answer is re-derived rather than remembered — "a board can therefore
|
||||
/// change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects
|
||||
/// what it finds."
|
||||
/// what it finds." `BoardGitEntryProbeTests` and `BoardGitModeDenialTests` below pin the
|
||||
/// 2026-08-06 axis on top of it: "Denial is not absence" — a check the sandbox refuses must read as
|
||||
/// `.unverifiable`, never as `.none`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
@@ -127,3 +129,150 @@ struct BoardGitModeTests {
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry probe classification
|
||||
|
||||
/// **The errno-aware probe underneath detection** (06-history-undo.md ▸ Rules ▸ Detection, "Denial
|
||||
/// is not absence", ruled 2026-07-31) — pinned directly, one test per classification, before the
|
||||
/// walk that builds on it is asked to prove anything.
|
||||
///
|
||||
/// The `denied` cases chmod a real directory to `0o000` — tests run unprivileged, so `EACCES` is
|
||||
/// genuinely reachable this way — and restore it with an explicit `defer` declared *after* the
|
||||
/// fixture's own teardown defer, so it runs first (Swift's LIFO defer order):
|
||||
/// `BoardDuplicatorTests.aFailedWalkRemovesThePartialSibling` is the precedent this mirrors, so a
|
||||
/// failed assertion can never leave an unremovable temp directory behind.
|
||||
@Suite("Board git mode ▸ entry probe")
|
||||
struct BoardGitEntryProbeTests {
|
||||
|
||||
@Test("A `.git` directory probes as exists")
|
||||
func probesExists() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
|
||||
#expect(BoardGitMode.probeGitEntry(at: fixture.root) == .exists)
|
||||
#expect(BoardGitMode.hasGitEntry(at: fixture.root), "the boolean convenience agrees")
|
||||
}
|
||||
|
||||
@Test("A plain folder with no `.git` probes as absent")
|
||||
func probesAbsent() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
#expect(BoardGitMode.probeGitEntry(at: fixture.root) == .absent)
|
||||
#expect(!BoardGitMode.hasGitEntry(at: fixture.root))
|
||||
}
|
||||
|
||||
@Test("A folder somewhere the sandbox denies traversal probes as denied, not absent")
|
||||
func probesDenied() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let outer = fixture.root.appendingPathComponent("outer", isDirectory: true)
|
||||
let inner = outer.appendingPathComponent("inner", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: inner, withIntermediateDirectories: true)
|
||||
|
||||
// Chmod the *parent*, not the probed folder itself: resolving `inner/.git` needs search
|
||||
// permission on `outer`, which a plain unix permission bit can deny for the test's own
|
||||
// unprivileged user exactly as the sandbox denies an ungranted ancestor.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: outer.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: outer.path) }
|
||||
|
||||
#expect(BoardGitMode.probeGitEntry(at: inner) == .denied)
|
||||
#expect(!BoardGitMode.hasGitEntry(at: inner), "the boolean convenience collapses denied to false, like absent")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Denial-aware detection
|
||||
|
||||
/// **The walk semantics denial adds** (06 ▸ Rules ▸ Detection): a denied ancestor never ends the
|
||||
/// walk early, because a farther ancestor's `.git` still makes repo-nested certain; only a walk that
|
||||
/// finds nothing at all *and* saw a denial along the way reads `.unverifiable`.
|
||||
@Suite("Board git mode ▸ denial-aware detection")
|
||||
struct BoardGitModeDenialTests {
|
||||
|
||||
@Test("A denied board-root probe is unverifiable outright — the walk never runs")
|
||||
func deniedRootProbeIsUnverifiable() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let boardRoot = fixture.root.appendingPathComponent("board", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: boardRoot.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: boardRoot.path) }
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
|
||||
}
|
||||
|
||||
@Test("A denied ancestor with nothing found anywhere else reads unverifiable")
|
||||
func deniedAncestorWithNothingFoundIsUnverifiable() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true)
|
||||
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
|
||||
|
||||
let walk = BoardGitMode.ancestorWalk(above: boardRoot)
|
||||
#expect(walk.root == nil)
|
||||
#expect(walk.sawDenial)
|
||||
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
|
||||
}
|
||||
|
||||
@Test("A denied nearer ancestor never hides a `.git` on a farther one — repo-nested is certain")
|
||||
func deniedAncestorWithARepositoryFartherUpIsRepoNested() throws {
|
||||
// **A note on what chmod can and cannot simulate**: the sandbox denies a *specific path*
|
||||
// independently of the filesystem's own permission bits — an ancestor above the board's
|
||||
// grant can be denied while the board root itself, inside the grant, stays fully readable.
|
||||
// POSIX `chmod`, in contrast, cascades: removing search permission from a real ancestor
|
||||
// directory denies resolving *everything* beneath it, board root included, which is a
|
||||
// strictly stronger (and still individually honest) denial than the sandbox's. So this test
|
||||
// proves the walk's own claim directly — `ancestorWalk(above:)` never touches `boardRoot`
|
||||
// itself, only the candidates above it, and is unaffected by that cascade.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true)
|
||||
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
|
||||
|
||||
// "A farther ancestor showing `.git` makes repo-nested certain regardless of the denied
|
||||
// nearer one — nearest-wins only affects which root you'd name, not whether one exists."
|
||||
let walk = BoardGitMode.ancestorWalk(above: boardRoot)
|
||||
#expect(walk.root?.standardizedFileURL == fixture.root.standardizedFileURL)
|
||||
#expect(walk.sawDenial, "the denial is still recorded, even though it didn't decide the outcome")
|
||||
|
||||
// `detect(boardRoot:)` itself reads `.unverifiable` here — not `.repoNested` — but for the
|
||||
// cascade reason above, not because the walk's certainty claim is false: `blocked` sits
|
||||
// between the filesystem root and `boardRoot`, so chmoding it also denies **`boardRoot`'s
|
||||
// own** `.git` probe, and `detect` answers that denial before the ancestor walk ever runs
|
||||
// (06 ▸ Rules: "probe the board root's `.git` first … denied → `.unverifiable`"). A real
|
||||
// sandboxed board, whose own root sits inside the grant, would not hit this path — its own
|
||||
// probe would succeed and the walk above is what would then run and find `.repoNested`.
|
||||
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
|
||||
}
|
||||
|
||||
@Test("All-clean paths are unaffected: none, git, and repo-nested still read as before")
|
||||
func cleanPathsAreUnaffected() throws {
|
||||
let plain = try WriterFixture()
|
||||
defer { plain.tearDown() }
|
||||
try plain.item("", Item.board)
|
||||
#expect(BoardGitMode.detect(boardRoot: plain.root) == .none)
|
||||
|
||||
let gitBoard = try WriterFixture()
|
||||
defer { gitBoard.tearDown() }
|
||||
try makeGitDirectory(at: gitBoard.root)
|
||||
#expect(BoardGitMode.detect(boardRoot: gitBoard.root) == .git)
|
||||
|
||||
let nested = try WriterFixture()
|
||||
defer { nested.tearDown() }
|
||||
try makeGitDirectory(at: nested.root)
|
||||
let board = try makeSubfolder(nested, named: "project/docs/board")
|
||||
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,18 @@ struct BoardGitSectionTests {
|
||||
#expect(section != .noRepository)
|
||||
}
|
||||
|
||||
@Test("An unverifiable board gets its own posture — structurally like nested, never the same case")
|
||||
func unverifiableIsItsOwnPostureNotRepoNested() {
|
||||
// "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check
|
||||
// is not a found repository, so the two must resolve to different cases even though both are
|
||||
// action-less, prose-only sections.
|
||||
let section = BoardGitSection.resolve(tier: .pro, mode: .unverifiable, hasGitDirectory: false)
|
||||
|
||||
#expect(section == .unverifiable)
|
||||
#expect(section != .repoNested, "a denial is not a nesting")
|
||||
#expect(section != .noRepository)
|
||||
}
|
||||
|
||||
@Test("Every posture is reachable, and none of them is two postures")
|
||||
func theMatrixIsTotal() {
|
||||
let resolved = Set(
|
||||
@@ -129,7 +141,7 @@ struct BoardInfoTitlebarSummaryTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An inert .git under Pro — mode none or repo-nested — never shows a branch")
|
||||
@Test("An inert .git under Pro — mode none, repo-nested, or unverifiable — never shows a branch")
|
||||
func inertGitNeverShowsABranch() {
|
||||
#expect(
|
||||
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil
|
||||
@@ -138,6 +150,10 @@ struct BoardInfoTitlebarSummaryTests {
|
||||
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch
|
||||
== nil
|
||||
)
|
||||
#expect(
|
||||
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .unverifiable, branch: "main").branch
|
||||
== nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A git-mode board whose branch has not been read yet shows none, honestly")
|
||||
|
||||
@@ -37,6 +37,14 @@ struct BoardSettingsSectionTests {
|
||||
#expect(offered == Set(BoardSettingsSection.allCases))
|
||||
}
|
||||
|
||||
@Test("Pro, unverifiable: the sheet has nothing to show — structurally like repo-nested")
|
||||
func unverifiableHoldsNothing() {
|
||||
// "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check
|
||||
// can never be told apart from a repository actually being there, so add-git stays as
|
||||
// unreachable here as it is on a genuinely nested board.
|
||||
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == [])
|
||||
}
|
||||
|
||||
@Test("The sections carry the headers VoiceOver navigates by")
|
||||
func headersAreNamed() {
|
||||
// 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver
|
||||
@@ -65,6 +73,10 @@ struct BoardSettingsAvailabilityTests {
|
||||
// popover's explanation stands and no door opens.
|
||||
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested))
|
||||
|
||||
// **Pro, unverifiable**: the same unreachability, for the denial-not-absence reason — a
|
||||
// denied ancestor check is never distinguishable from a repository actually being there.
|
||||
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable))
|
||||
|
||||
// **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and
|
||||
// `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is
|
||||
// swept for completeness rather than because it can vary.
|
||||
|
||||
@@ -1123,6 +1123,30 @@ struct GitUndoBindingTests {
|
||||
#expect(session.git?.committer == nil, "no committer, and so nothing that could write there")
|
||||
}
|
||||
|
||||
@Test("The default provider closure binds the native stack on an unverifiable board too")
|
||||
func proOnAnUnverifiableBoardBindsTheNativeStack() throws {
|
||||
// "`unverifiable` joins the same branch structurally" (`AppModel.makeHistoryProvider`) — a
|
||||
// denied ancestor check is no more a repository the app manages than a repo-nested one is.
|
||||
//
|
||||
// This exercises the seam directly rather than through `openBoard`: reaching `.unverifiable`
|
||||
// on a real board needs a denied *ancestor*, and POSIX permission bits (unlike the sandbox's
|
||||
// independent per-path grants) cascade — chmoding a real ancestor to deny its `.git` check
|
||||
// also denies reading the board's own files underneath it, so the board could never actually
|
||||
// open (`BoardGitModeDenialTests` in `BoardGitModeTests.swift` covers the detection axis
|
||||
// itself against real denied directories; this covers what the composition root does with
|
||||
// whatever mode a `HistoryStore` reports, real detection or not).
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger())
|
||||
let provider = model.makeHistoryProvider(store, .pro, git)
|
||||
|
||||
#expect(provider is NativeHistoryProvider)
|
||||
}
|
||||
|
||||
@Test("The free tier's repo-nested board still binds the native stack — it never detects one")
|
||||
func freeTierOnARepoNestedBoardIsNativeToo() throws {
|
||||
let outer = try WriterFixture()
|
||||
|
||||
@@ -323,6 +323,36 @@ struct HistoryStoreAddGitTests {
|
||||
#expect(git.mode == .none, "a refused add-git changes nothing, mode included")
|
||||
}
|
||||
|
||||
@Test("Create refuses a board that a fresh detection reads unverifiable — a denied ancestor")
|
||||
func createRefusesUnverifiable() throws {
|
||||
// **The tightened guard** (06 ▸ Rules ▸ Detection): "only a genuinely clean `.none` reading
|
||||
// proceeds" — a stale `.none` that has since become unverifiable is refused exactly like one
|
||||
// that has since become repo-nested (`createRefusesAStaleModeNone` above).
|
||||
let outer = try WriterFixture()
|
||||
defer { outer.tearDown() }
|
||||
let blocked = outer.root.appendingPathComponent("blocked", isDirectory: true)
|
||||
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable, "the fixture is set up correctly")
|
||||
|
||||
let failure = GitRepository.create(at: boardRoot)
|
||||
guard case .failure(let reason) = failure else {
|
||||
Issue.record("initializing where detection cannot rule out a repository must be refused")
|
||||
return
|
||||
}
|
||||
#expect(reason.operation == "Adding git to this board")
|
||||
#expect(!reason.message.isEmpty)
|
||||
#expect(
|
||||
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
|
||||
"no repository created on an unverifiable read"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A failure answers at the form when it is up, and at the banner when it is not")
|
||||
@MainActor
|
||||
func aFailureAnswersAtTheFormOrTheBanner() async throws {
|
||||
|
||||
Reference in New Issue
Block a user