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:
@@ -30,9 +30,9 @@ import os
|
||||
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
|
||||
/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
|
||||
/// face), and the snapshot is where it reads from.
|
||||
/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is
|
||||
/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
|
||||
/// - `looseFileNames(in:ignoring:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`.
|
||||
/// This is the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸
|
||||
/// Rules, settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
|
||||
/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
|
||||
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
|
||||
/// Writer-mediated app write the store schedules off the snapshot
|
||||
@@ -40,6 +40,10 @@ import os
|
||||
///
|
||||
/// Everything else about a card folder's contents remains outside this loader's business.
|
||||
///
|
||||
/// **A third read, once per walk rather than per card**: the board root's `.gitignore`, which is the
|
||||
/// noise gate the detection above obeys (§ Rules, ruled 2026-07-31 — `ignoreRules(atBoardRoot:)`). A
|
||||
/// file it matches is not a defect at all; it keeps the ordinary stray posture.
|
||||
///
|
||||
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
||||
/// followed, whether it points to a file or a directory — this loader does not resolve
|
||||
/// cross-volume or cyclic trees.
|
||||
@@ -49,7 +53,7 @@ import os
|
||||
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
|
||||
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
|
||||
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
|
||||
/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the
|
||||
/// the lanes, parsing its UUID-shaped children through the same `schema`/`order` rulebook and the
|
||||
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
|
||||
/// warns; absent, the trash is simply empty.
|
||||
///
|
||||
@@ -142,8 +146,41 @@ public enum BoardLoader: Sendable {
|
||||
/// table itself is `IntegrityRules`', with every other reserved name.
|
||||
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
||||
|
||||
/// The board's noise definition, at board root (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names.
|
||||
static let gitignoreFileName = IntegrityRules.gitignoreFileName
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
||||
|
||||
// MARK: - The noise gate
|
||||
|
||||
/// **The board-root `.gitignore`, parsed** — the one file the loose-file carve-out consults
|
||||
/// before calling anything relocatable (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-31: "`.gitignore` is the noise gate").
|
||||
///
|
||||
/// Read **once per walk**, at the top of `load`, and handed down to every card: a board with 400
|
||||
/// cards reads this file once, not 400 times. `nil` when the board has none — and `nil` and an
|
||||
/// empty file mean exactly the same thing to every caller (nothing is excluded), which is the
|
||||
/// ruling's own escape hatch working by construction rather than by a branch.
|
||||
///
|
||||
/// **Board root only.** "Nested `.gitignore` files are ordinary strays the heal never consults" —
|
||||
/// a `.gitignore` inside a lane or a card is a file like any other, and one inside a card is
|
||||
/// itself a hidden entry the carve-out never touches.
|
||||
///
|
||||
/// Reading it here does not make the loader impure: this is a read like `index.md`'s, on a file
|
||||
/// the walk's result depends on. Nothing is written, and an unreadable or non-UTF-8 file answers
|
||||
/// `nil` — "cannot tell" reads as "excludes nothing", which is the direction that loses no file
|
||||
/// (an unrelocated stray stays exactly where it is; the alternative would silently move things
|
||||
/// the user meant to be noise).
|
||||
static func ignoreRules(atBoardRoot root: URL) -> GitignoreRules? {
|
||||
guard let data = try? Data(contentsOf: root.appendingPathComponent(gitignoreFileName)),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return GitignoreRules(parsing: text)
|
||||
}
|
||||
|
||||
// MARK: - Entry point
|
||||
|
||||
public static func load(
|
||||
@@ -157,7 +194,10 @@ public enum BoardLoader: Sendable {
|
||||
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
|
||||
}
|
||||
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
|
||||
let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName)
|
||||
// **The root's own `schema` stays required** (01-storage-format.md § Malformed input,
|
||||
// re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on the
|
||||
// board that does not read as 1 when absent.
|
||||
let boardSchema = try validatedRootSchema(in: boardDocument, path: indexFileName)
|
||||
|
||||
var warnings: [LoadWarning] = []
|
||||
func warn(_ warning: LoadWarning) {
|
||||
@@ -182,8 +222,18 @@ public enum BoardLoader: Sendable {
|
||||
// `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
|
||||
// line exists to be findable later rather than to be noticed now ("no banner, no behavior
|
||||
// change").
|
||||
func noteCoercions(in document: FrontmatterDocument, at path: String) {
|
||||
let fields = document.coercedFields
|
||||
//
|
||||
// `plus` carries the records only the rulebook can make: `order` and `schema` below the root
|
||||
// (re-ruled 2026-07-31 — the optional-key readings). They cannot come from
|
||||
// `document.coercedFields`, which reads *present* fields that had no sensible reading and by
|
||||
// construction knows nothing about a key that is simply absent, nor about which level the
|
||||
// file sits at.
|
||||
func noteCoercions(
|
||||
in document: FrontmatterDocument,
|
||||
at path: String,
|
||||
plus extra: [CoercedField] = []
|
||||
) {
|
||||
let fields = extra + document.coercedFields
|
||||
guard !fields.isEmpty else { return }
|
||||
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
|
||||
for field in fields {
|
||||
@@ -214,6 +264,11 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
noteCoercions(in: boardDocument, at: indexFileName)
|
||||
|
||||
// **The noise gate, read once for the whole walk** (01-storage-format.md § Fractal layout ▸
|
||||
// Rules, ruled 2026-07-31): the board's `.gitignore` is what decides which loose files are
|
||||
// work and which are ordinary strays, and it is one file per board — not one per card.
|
||||
let ignoreRules = ignoreRules(atBoardRoot: boardRoot)
|
||||
|
||||
// Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide
|
||||
// identity dedupe below decides which folders render at all, and a `Lane` is built only on
|
||||
// the far side of that decision, because a `Lane` carrying a withheld card would be exactly
|
||||
@@ -236,11 +291,18 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
let lanePath = laneName + "/" + indexFileName
|
||||
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
|
||||
let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
|
||||
let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)
|
||||
noteCoercions(in: laneDocument, at: lanePath)
|
||||
// Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema` reads
|
||||
// as 1, a missing or unusable `order` as append-at-end. Both readings are coerce-tier —
|
||||
// recorded here, logged, and acted on by nothing until the file's next Writer touch.
|
||||
let laneSchema = try resolvedSchema(in: laneDocument, path: lanePath)
|
||||
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
|
||||
noteCoercions(
|
||||
in: laneDocument,
|
||||
at: lanePath,
|
||||
plus: [laneSchema.coerced, laneOrder.coerced].compactMap { $0 }
|
||||
)
|
||||
|
||||
var cards: [Card] = []
|
||||
var walkedCards: [WalkedCard] = []
|
||||
for cardURL in try directoryCandidates(in: laneURL) {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = laneName + "/" + cardName
|
||||
@@ -254,7 +316,7 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
let card = try parseCard(at: cardURL, path: cardRelPath)
|
||||
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName)
|
||||
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)
|
||||
|
||||
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
// extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing
|
||||
@@ -269,8 +331,11 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer. What
|
||||
// the board's `.gitignore` excludes never becomes a defect at all — it keeps the
|
||||
// stray posture, so there is nothing here for the store to schedule and nothing for
|
||||
// a banner to announce.
|
||||
let loose = looseFileNames(in: cardURL, ignoring: ignoreRules)
|
||||
if !loose.isEmpty {
|
||||
defects.append(.looseCardFiles(LooseCardFiles(
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
@@ -292,9 +357,18 @@ public enum BoardLoader: Sendable {
|
||||
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
||||
}
|
||||
|
||||
cards.append(card)
|
||||
walkedCards.append(card)
|
||||
}
|
||||
|
||||
// **The append-at-end reading, settled per container** (01-storage-format.md § Ordering,
|
||||
// re-ruled 2026-07-31): every card's rank is the one it wrote down, or — where it wrote
|
||||
// none — a materialized rank past every ordered sibling, ordered among the other
|
||||
// order-less ones by folder name. Settled here, over the lane's whole card list, because
|
||||
// that is the smallest scope in which "past every ordered sibling" is a fact.
|
||||
let cardOrders = Ranks.resolvedOrders(
|
||||
of: walkedCards, stored: \.storedOrder, name: { $0.id.rawValue })
|
||||
let cards = zip(walkedCards, cardOrders).map { $0.rendered(order: $1) }
|
||||
|
||||
// **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
|
||||
// § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
|
||||
// live with the key ignored — no migration machinery, no key-strip write, no notice
|
||||
@@ -307,20 +381,34 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
walkedLanes.append(WalkedLane(
|
||||
name: laneName,
|
||||
schema: laneSchema,
|
||||
order: laneOrder,
|
||||
schema: laneSchema.schema,
|
||||
storedOrder: laneOrder.order,
|
||||
document: laneDocument,
|
||||
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue })
|
||||
))
|
||||
}
|
||||
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
||||
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
||||
/// sorted by `modified` descending since 2026-07-31.
|
||||
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
||||
// The strip's own append-at-end reading — the card rule one level up, and the reason the
|
||||
// lane loop above carried a *stored* order: a lane with no rank sorts past every ranked one,
|
||||
// which cannot be known until the last lane folder has been read.
|
||||
for (index, order) in Ranks.resolvedOrders(
|
||||
of: walkedLanes, stored: \.storedOrder, name: \.name
|
||||
).enumerated() {
|
||||
walkedLanes[index].order = order
|
||||
}
|
||||
|
||||
/// Every trash entry as the walk read it, kind decided but rank not yet — the `WalkedCard`
|
||||
/// intermediate's job in the flat container, in a tuple because the split into the snapshot's
|
||||
/// two arrays happens on the far side of the append-at-end reading.
|
||||
var walkedTrash: [(
|
||||
id: ItemID,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
schema: Int,
|
||||
storedOrder: Double?,
|
||||
heldCards: Int,
|
||||
attachments: [String],
|
||||
document: FrontmatterDocument
|
||||
)] = []
|
||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for entryURL in trashCandidates(in: boardRoot) {
|
||||
let entryName = entryURL.lastPathComponent
|
||||
@@ -333,14 +421,18 @@ public enum BoardLoader: Sendable {
|
||||
warn(.missingIndex(path: entryRelPath))
|
||||
continue
|
||||
}
|
||||
// The two kinds are validated identically — `schema` and `order` are required of both
|
||||
// (`IntegrityRules.requiresOrder`) — so the strict parse happens once, before the
|
||||
// discriminator, and a malformed entry fails fast whichever kind it turns out to be.
|
||||
// The two kinds are read identically — the same optional-`schema`/optional-`order`
|
||||
// rulebook below the root — so the parse happens once, before the discriminator, and a
|
||||
// schema newer than this app fails fast whichever kind the entry turns out to be.
|
||||
let entryPath = entryRelPath + "/" + indexFileName
|
||||
let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
|
||||
let schema = try validatedSchema(in: document, path: entryPath)
|
||||
let order = try validatedOrder(in: document, path: entryPath)
|
||||
noteCoercions(in: document, at: entryPath)
|
||||
let schema = try resolvedSchema(in: document, path: entryPath)
|
||||
let order = IntegrityRules.resolvedOrder(in: document)
|
||||
noteCoercions(
|
||||
in: document,
|
||||
at: entryPath,
|
||||
plus: [schema.coerced, order.coerced].compactMap { $0 }
|
||||
)
|
||||
|
||||
// **The trash's discriminator, applied where the flat container needs it**
|
||||
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
||||
@@ -362,30 +454,60 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
let id = ItemID(rawValue: entryName)
|
||||
trashKinds[id] = kind
|
||||
trashEntries.append((id: id, title: document.title.value, modified: document.modified.value))
|
||||
|
||||
switch kind {
|
||||
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
|
||||
// showing its title and held-card count). The count is the same listing the shape
|
||||
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and a
|
||||
// kindless one pays for none extra — and a card pays for its attachment listing only,
|
||||
// which is why each side is read under its own arm rather than unconditionally.
|
||||
//
|
||||
// Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` treats
|
||||
// both as unrecognized and answers by shape — so the non-lane arm is the card answer and
|
||||
// nothing else.
|
||||
let isLane = kind == .lane
|
||||
walkedTrash.append((
|
||||
id: id,
|
||||
kind: kind,
|
||||
schema: schema.schema,
|
||||
storedOrder: order.order,
|
||||
heldCards: isLane ? children().count : 0,
|
||||
attachments: isLane ? [] : attachmentNames(in: entryURL),
|
||||
document: document
|
||||
))
|
||||
}
|
||||
|
||||
// The trash's own append-at-end reading, over the container as one flat list. `order` decides
|
||||
// nothing about where a trash row *sits* — that is `modified`'s job since 2026-07-31 — but
|
||||
// every entry carries a rank for its eventual restore, and an entry that carries none reads
|
||||
// like every other order-less file rather than getting a container-specific rule of its own.
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
||||
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
||||
/// sorted by `modified` descending since 2026-07-31.
|
||||
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
||||
let trashOrders = Ranks.resolvedOrders(
|
||||
of: walkedTrash, stored: { $0.storedOrder }, name: { $0.id.rawValue })
|
||||
for (entry, order) in zip(walkedTrash, trashOrders) {
|
||||
let document = entry.document
|
||||
trashEntries.append((
|
||||
id: entry.id, title: document.title.value, modified: document.modified.value))
|
||||
switch entry.kind {
|
||||
case .lane:
|
||||
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
|
||||
// showing its title and held-card count). The count is the same listing the shape
|
||||
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and
|
||||
// a kindless one pays for none extra.
|
||||
trashedLanes.append(TrashedLane(
|
||||
id: id,
|
||||
schema: schema,
|
||||
id: entry.id,
|
||||
schema: entry.schema,
|
||||
title: document.title,
|
||||
modified: document.modified,
|
||||
order: order,
|
||||
heldCards: children().count,
|
||||
heldCards: entry.heldCards,
|
||||
document: document
|
||||
))
|
||||
case .card, .board, .comment:
|
||||
// Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind`
|
||||
// treats both as unrecognized and answers by shape — so this arm is the card answer
|
||||
// and nothing else.
|
||||
trash.append(Card(
|
||||
id: id,
|
||||
schema: schema,
|
||||
id: entry.id,
|
||||
schema: entry.schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
@@ -395,7 +517,7 @@ public enum BoardLoader: Sendable {
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
attachments: attachmentNames(in: entryURL),
|
||||
attachments: entry.attachments,
|
||||
document: document
|
||||
))
|
||||
}
|
||||
@@ -579,7 +701,13 @@ public enum BoardLoader: Sendable {
|
||||
private struct WalkedLane {
|
||||
let name: String
|
||||
let schema: Int
|
||||
let order: Double
|
||||
/// The rank the file actually carries, `nil` where it carries none this app can use.
|
||||
let storedOrder: Double?
|
||||
/// The append-at-end reading, filled in once every sibling lane has been read
|
||||
/// (`Ranks.resolvedOrders(of:stored:name:)` — 01-storage-format.md § Ordering, re-ruled
|
||||
/// 2026-07-31). A `var` on a private walk value for the same reason the type exists at all:
|
||||
/// the container settles it, and the `Lane` is built on the far side of that.
|
||||
var order: Double = 0
|
||||
let document: FrontmatterDocument
|
||||
/// Already in display order — the traversal the dedupe's last tie-break is stated in.
|
||||
let cards: [Card]
|
||||
@@ -648,35 +776,67 @@ public enum BoardLoader: Sendable {
|
||||
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
||||
}
|
||||
|
||||
/// One card folder read into a `Card` — the lane walk's card parse.
|
||||
/// One card as the walk read it, before its container's ranks resolved — a `Card` minus the
|
||||
/// append-at-end reading, which cannot be settled until every sibling's stored `order` is known
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31). `WalkedLane`'s shape, one level down
|
||||
/// and for the same kind of reason: a value the container decides is not a value the item can
|
||||
/// carry while it is still being read.
|
||||
private struct WalkedCard {
|
||||
let id: ItemID
|
||||
let schema: Int
|
||||
/// The rank the file actually carries, `nil` where it carries none this app can use — the
|
||||
/// input to `Ranks.resolvedOrders(of:stored:name:)`.
|
||||
let storedOrder: Double?
|
||||
let attachments: [String]
|
||||
let document: FrontmatterDocument
|
||||
/// This card's coerce-tier records for the strict fields, which only the rulebook can make
|
||||
/// (a missing key leaves no trace in `document.coercedFields`).
|
||||
let coercions: [CoercedField]
|
||||
|
||||
var title: FieldValue<String> { document.title }
|
||||
var isDeleted: Bool { !document.deleted.isMissing }
|
||||
|
||||
func rendered(order: Double) -> Card {
|
||||
Card(
|
||||
id: id,
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
deleted: document.deleted,
|
||||
background: document.background,
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
attachments: attachments,
|
||||
document: document
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One card folder read into a `WalkedCard` — the lane walk's card parse.
|
||||
///
|
||||
/// The trash's own walk reads its entries inline instead, because the container is flat and its
|
||||
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict
|
||||
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
|
||||
/// function that has already decided what it is reading.
|
||||
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their
|
||||
/// `schema`/`order` rulebook (`IntegrityRules`, one rule) rather than sharing a function that
|
||||
/// has already decided what it is reading.
|
||||
///
|
||||
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
||||
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
|
||||
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card {
|
||||
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> WalkedCard {
|
||||
let cardPath = path + "/" + indexFileName
|
||||
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
|
||||
let schema = try validatedSchema(in: document, path: cardPath)
|
||||
let order = try validatedOrder(in: document, path: cardPath)
|
||||
let schema = try resolvedSchema(in: document, path: cardPath)
|
||||
let order = IntegrityRules.resolvedOrder(in: document)
|
||||
|
||||
return Card(
|
||||
return WalkedCard(
|
||||
id: ItemID(rawValue: cardURL.lastPathComponent),
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
deleted: document.deleted,
|
||||
background: document.background,
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
schema: schema.schema,
|
||||
storedOrder: order.order,
|
||||
attachments: attachmentNames(in: cardURL),
|
||||
document: document
|
||||
document: document,
|
||||
coercions: [schema.coerced, order.coerced].compactMap { $0 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -782,7 +942,7 @@ public enum BoardLoader: Sendable {
|
||||
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
|
||||
/// is a pure function of the tree and stays one.
|
||||
///
|
||||
/// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
|
||||
/// Five exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
|
||||
///
|
||||
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
|
||||
/// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
|
||||
@@ -795,14 +955,32 @@ public enum BoardLoader: Sendable {
|
||||
/// would surface it in a card's attachment list — the loudest possible way to be wrong about
|
||||
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
|
||||
/// residue out of the relocation.
|
||||
///
|
||||
/// **This survived the noise-gate ruling** (2026-07-31), which retired "nothing hardcoded"
|
||||
/// exclusions in favour of the seeded `.gitignore`, because it is not a noise heuristic and
|
||||
/// never was a `.DS_Store` special case: `attachmentNames(in:)` skips hidden entries too, so a
|
||||
/// relocated hidden file would land in a folder whose listing can never show it — the move
|
||||
/// would take a file the user can see in Finder beside `index.md` and put it somewhere the app
|
||||
/// is structurally unable to surface. The carve-out exists to put a card's files where the app
|
||||
/// *shows* them; a hidden file has no such destination. What the ruling retires is the app
|
||||
/// having a second opinion about *which* visible files are noise, and it never had one.
|
||||
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
|
||||
/// - **Whatever the board's `.gitignore` excludes** (§ Rules, ruled 2026-07-31 — the noise
|
||||
/// gate): "a file matching the board-root `.gitignore` … keeps the ordinary stray posture:
|
||||
/// skipped, preserved verbatim, logged, never relocated, never announced". Matched against the
|
||||
/// file's **board-relative** path (`<lane>/<card>/<name>`), because that is the path git would
|
||||
/// match and because an anchored pattern (`/notes.txt`) has to mean the board root rather than
|
||||
/// every card in it. `rules` is `nil` on a board that carries no such file, which excludes
|
||||
/// nothing — the pre-ruling behaviour, and the same answer an empty file gives.
|
||||
///
|
||||
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
|
||||
/// the store posts names files the way the board would sort them.
|
||||
///
|
||||
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
|
||||
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
|
||||
static func looseFileNames(in cardFolder: URL) -> [String] {
|
||||
/// `rules` has no default on purpose: the gate is the ruling, and a caller that could forget to
|
||||
/// pass it would be a second, quieter definition of what counts as noise.
|
||||
static func looseFileNames(in cardFolder: URL, ignoring rules: GitignoreRules?) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: cardFolder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
@@ -811,19 +989,40 @@ public enum BoardLoader: Sendable {
|
||||
return []
|
||||
}
|
||||
|
||||
let cardPath = boardRelativeCardPath(of: cardFolder)
|
||||
return entries
|
||||
.filter { url in
|
||||
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
|
||||
values.isRegularFile == true, values.isSymbolicLink != true
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
// The noise gate. Logged rather than silent, because "skipped, preserved verbatim,
|
||||
// logged" is the stray posture this file is being *given*, and a file that stays put
|
||||
// for a reason nobody can see is the one the user files a bug about.
|
||||
let name = url.lastPathComponent
|
||||
guard rules?.isIgnored(relativePath: cardPath + "/" + name) == true else { return true }
|
||||
logger.info(
|
||||
"\(cardPath, privacy: .public)/\(name, privacy: .public): matched by the board's \(gitignoreFileName, privacy: .public) — left where it is, not relocated"
|
||||
)
|
||||
return false
|
||||
}
|
||||
.map(\.lastPathComponent)
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// A card folder's board-relative path — `<lane>/<card>` — read off the URL rather than passed
|
||||
/// in, so the one caller that has no walk behind it (the Writer's import-boundary normalization)
|
||||
/// gets the same answer the walk would have given.
|
||||
///
|
||||
/// The depth is fixed by the schema: a card is `<root>/<lane>/<card>` and nothing else
|
||||
/// (`BoardWriter.checkIsCardFolder` enforces exactly this shape before any relocation), so two
|
||||
/// components are the whole of it.
|
||||
private static func boardRelativeCardPath(of cardFolder: URL) -> String {
|
||||
cardFolder.deletingLastPathComponent().lastPathComponent + "/" + cardFolder.lastPathComponent
|
||||
}
|
||||
|
||||
/// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
|
||||
/// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
|
||||
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
|
||||
@@ -943,11 +1142,13 @@ public enum BoardLoader: Sendable {
|
||||
/// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
|
||||
/// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
|
||||
///
|
||||
/// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its
|
||||
/// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not
|
||||
/// newer than this app) and `order` (present, well-formed) — the two fields a card must carry.
|
||||
/// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional,
|
||||
/// unknown keys are the whole point of the outlet, and the body is free text.
|
||||
/// **Exactly the checks `load(boardRoot:)` runs on a card**, in its order and through its own
|
||||
/// functions: decode + parse (`parseDocument`), then `schema` at the below-the-root reading
|
||||
/// (missing reads as 1; malformed, or newer than this app, still refuses). Nothing card-shaped
|
||||
/// is checked beyond that, because nothing else *is*: `title` is optional, **`order` is optional
|
||||
/// too** since 2026-07-31 — a card applied without one lands at its lane's bottom and gains a
|
||||
/// rank on its next touch — unknown keys are the whole point of the outlet, and the body is free
|
||||
/// text.
|
||||
///
|
||||
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
|
||||
/// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
|
||||
@@ -968,13 +1169,18 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
/// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸
|
||||
/// Components). These two forward so the walk above reads as it always did.
|
||||
private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
|
||||
try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
/// Components). These forward so the walk above reads as it always did.
|
||||
private static func validatedRootSchema(
|
||||
in document: FrontmatterDocument, path: String
|
||||
) throws(BoardLoadError) -> Int {
|
||||
try IntegrityRules.validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
|
||||
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
|
||||
try IntegrityRules.validatedOrder(in: document, path: path)
|
||||
/// `schema` below the root — missing reads as 1, coerce-tier (re-ruled 2026-07-31).
|
||||
private static func resolvedSchema(
|
||||
in document: FrontmatterDocument, path: String
|
||||
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
|
||||
try IntegrityRules.resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,11 +1359,23 @@ public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertibl
|
||||
/// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
|
||||
/// underlying error carries one.
|
||||
case unparseableYAML(message: String, line: Int?)
|
||||
/// **The board root's own missing `schema`** — the this-really-is-a-board gate (re-ruled
|
||||
/// 2026-07-31). Below the root a missing `schema` reads as 1 instead, coerce-tier.
|
||||
case missingSchema
|
||||
case malformedSchema(raw: String)
|
||||
/// `schema` is present, valid, and greater than this app's `supportedSchema`.
|
||||
case schemaNewerThanApp(found: Int)
|
||||
/// `order` is required on lanes and cards, never on the board itself.
|
||||
|
||||
/// **Retired, and nothing throws these any more** (01-storage-format.md § Ordering and
|
||||
/// § Malformed input, re-ruled 2026-07-31): below the board root a missing, null, non-numeric
|
||||
/// or non-finite `order` reads as append-at-end — coerce-tier, logged, bytes preserved
|
||||
/// (`IntegrityRules.resolvedOrder`) — and the board root never carried a rank to begin with.
|
||||
///
|
||||
/// They stay in the vocabulary rather than being deleted because this enum *is* the
|
||||
/// load-failure surface every banner, announcement and decision-surface row is written
|
||||
/// against, and a reason that can no longer occur costs those surfaces nothing while removing
|
||||
/// one would rewrite them for a rule that changed underneath, not for a shape they render
|
||||
/// differently.
|
||||
case missingOrder
|
||||
case malformedOrder(raw: String)
|
||||
/// The board root exists but is a file, not a directory.
|
||||
|
||||
Reference in New Issue
Block a user