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:
@@ -76,8 +76,15 @@ enum AgentGuide {
|
||||
/// defect was a missing second line, and the parser's complaint pointed at the *last* key it
|
||||
/// swallowed rather than the title. The guide now teaches long values as one long line, says
|
||||
/// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside
|
||||
/// the unquoted colon in Hard rules.
|
||||
static let version = 9
|
||||
/// the unquoted colon in Hard rules. **v10 makes `order` and `schema` optional below the board
|
||||
/// root** (01-storage-format.md § Frontmatter and § Ordering, re-ruled 2026-07-31): the guide's
|
||||
/// whole point is that filing a card must need nothing but the schema
|
||||
/// (08-agent-integration.md's masterplan requirement), and until now that was untrue — a card
|
||||
/// needed a rank, and a rank needed a scan of every sibling in the lane. The guide now teaches
|
||||
/// the zero-read minimum (`mkdir` plus one `index.md`, no `order`, no `schema`; it lands at the
|
||||
/// lane's bottom and the app stamps a real rank on its first touch) while still teaching
|
||||
/// *writing* `order` as the way to control position, which is the only way to control it.
|
||||
static let version = 10
|
||||
|
||||
// MARK: - The version marker
|
||||
|
||||
@@ -367,7 +374,8 @@ enum AgentGuide {
|
||||
## Reading the board
|
||||
|
||||
- Lanes run left→right by ascending `order`; cards top→bottom by ascending
|
||||
`order` within their lane. Ties break by folder name.
|
||||
`order` within their lane. Ties break by folder name. An item with no
|
||||
`order` sorts after every item that has one — see Creating a card.
|
||||
- Lane titles carry the workflow semantics (e.g. To Do → In Progress →
|
||||
Done). Read the board's and lanes' index.md bodies for descriptions and
|
||||
per-lane policy before deciding where a card belongs.
|
||||
@@ -376,13 +384,15 @@ enum AgentGuide {
|
||||
|
||||
## Frontmatter
|
||||
|
||||
All levels: `schema` (required, always `1`), `title` (optional — an item
|
||||
without one renders as untitled, so give cards real titles), `created` and
|
||||
`modified` (ISO-8601 with timezone, e.g. `2026-07-24T18:00:00Z`),
|
||||
`background` (color), `icon` (SF Symbol name), `iconColor` (color, tints
|
||||
`icon`). Lanes and cards additionally require `order` (a number; floats are
|
||||
fine). Lanes may set `width` (integer ≥ 1, multiplier of the standard lane
|
||||
width).
|
||||
All levels: `schema` (always `1`; **required at the board's own `index.md`**,
|
||||
optional below it — a lane or card without one is read as schema 1), `title`
|
||||
(optional — an item without one renders as untitled, so give cards real
|
||||
titles), `created` and `modified` (ISO-8601 with timezone, e.g.
|
||||
`2026-07-24T18:00:00Z`), `background` (color), `icon` (SF Symbol name),
|
||||
`iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
|
||||
floats are fine) — **optional, and the way to control position**: an item
|
||||
without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
|
||||
standard lane width).
|
||||
|
||||
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
||||
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
||||
@@ -418,9 +428,7 @@ enum AgentGuide {
|
||||
|
||||
## Creating a card
|
||||
|
||||
1. Pick the lane folder. Compute `order`: bottom of the lane = max existing
|
||||
card `order` + 1024; top = min − 1024; between two cards = their
|
||||
midpoint. (Empty lane: any number, conventionally 1024.)
|
||||
1. Pick the lane folder.
|
||||
2. Create a folder named a fresh lowercase UUID:
|
||||
`id=$(uuidgen | tr 'A-Z' 'a-z')`.
|
||||
3. Write `<lane>/$id/index.md` (timestamp: `date -u +%FT%TZ`):
|
||||
@@ -438,8 +446,31 @@ enum AgentGuide {
|
||||
The card's content — any Markdown.
|
||||
```
|
||||
|
||||
**`order` is what places the card, and computing it means reading the
|
||||
lane**: bottom of the lane = max existing card `order` + 1024; top =
|
||||
min − 1024; between two cards = their midpoint. (Empty lane: any number,
|
||||
conventionally 1024.) Write it whenever the position matters.
|
||||
|
||||
**You can also file a card without reading the lane at all.** The minimum
|
||||
legal card is a `mkdir` and one `index.md` containing nothing but a title —
|
||||
no `order`, no `schema`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Short imperative card title
|
||||
---
|
||||
The card's content.
|
||||
```
|
||||
|
||||
It lands at the bottom of the lane (an item with no `order` sorts after
|
||||
every item that has one; two such items sort by folder name), and the app
|
||||
writes a real `order` into it the next time it rewrites that file. Prefer
|
||||
the full frontmatter above — `kind`, the timestamps and `modified-by` are
|
||||
all worth having — but when you are filing into a 200-card lane and the
|
||||
position doesn't matter, the short form costs one write and no reads.
|
||||
|
||||
Creating a lane is the same one level up (body optional; `kind: lane`;
|
||||
`order` ranks lanes left→right).
|
||||
`order` ranks lanes left→right, and is optional in the same way).
|
||||
|
||||
**Always write `kind`** at creation — `kind: card`, `kind: lane`,
|
||||
`kind: board` at board root. Depth already says what an item is on the
|
||||
@@ -508,10 +539,11 @@ enum AgentGuide {
|
||||
|
||||
## Hard rules (the app fails loudly on violations)
|
||||
|
||||
- Frontmatter must parse as YAML; `schema` (plus `order` on lanes and
|
||||
cards) is required. Keep `schema: 1`. The classic violations are an
|
||||
unquoted colon in a title and a quoted value left unclosed across a
|
||||
line break (see Frontmatter above).
|
||||
- Frontmatter must parse as YAML. The board's own `index.md` must carry
|
||||
`schema: 1`; everywhere else `schema` and `order` are optional and a
|
||||
missing one is read, never refused. Never write a `schema` other than
|
||||
`1`. The classic violations are an unquoted colon in a title and a
|
||||
quoted value left unclosed across a line break (see Frontmatter above).
|
||||
- Files must be UTF-8 without BOM.
|
||||
- Never create a card folder without an `index.md`.
|
||||
- Never rename UUID folders.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -169,11 +169,19 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
/// Rank among lanes, ascending = left-to-right. Strict, per the frontmatter table's
|
||||
/// required field: `BoardLoader` fails the whole load (`missingOrder`/`malformedOrder`)
|
||||
/// rather than construct a `Lane` with a bad `order` — by the time one exists here it is
|
||||
/// always valid. Validity is the loader's job, not this type's; that is why it is a plain
|
||||
/// `Double` and not `FieldValue<Double>`.
|
||||
/// Rank among lanes, ascending = left-to-right — **the reading, not necessarily the key**
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: `order` is optional below the board
|
||||
/// root, and "missing or unusable reads as append-at-end").
|
||||
///
|
||||
/// A lane whose `index.md` carries a usable `order` reads as that number. One that carries none
|
||||
/// — no key, an explicit null, a non-numeric or non-finite value — reads as a rank past every
|
||||
/// ordered sibling, materialized by `Ranks.resolvedOrders(of:stored:name:)` and tie-broken among
|
||||
/// the other order-less lanes by folder name. Either way this is a plain, finite `Double` by the
|
||||
/// time a `Lane` exists, which is why it is not a `FieldValue<Double>`: resolving the reading is
|
||||
/// the loader's job, and the snapshot carries answers rather than shapes.
|
||||
///
|
||||
/// The value is real enough to write down, and the Writer does exactly that on the file's first
|
||||
/// touch (`IntegrityRules.OnTouchHeal.rankStamped`) — so the stamp changes nothing on screen.
|
||||
public let order: Double
|
||||
|
||||
/// Width multiplier ≥ 1 (default 1 when missing or malformed). Lenient — it styles layout,
|
||||
@@ -222,8 +230,10 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
|
||||
/// Rank within its lane, ascending = top-to-bottom. Strict — see `Lane.order`'s doc
|
||||
/// comment; the same reasoning applies here.
|
||||
/// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See
|
||||
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
|
||||
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
|
||||
/// (08-agent-integration.md), and it lands at the bottom of its lane.
|
||||
public let order: Double
|
||||
|
||||
/// The card's attachment file names — **flat: top-level regular files only, in Finder
|
||||
@@ -291,8 +301,10 @@ public struct TrashedLane: Identifiable, Sendable, Equatable {
|
||||
/// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites
|
||||
/// no `order` at all, so this is still the strip position a restore would want and the value the
|
||||
/// undo of a restore puts back. It is deliberately *not* what orders this row in the column
|
||||
/// (`modified` is), and the same required, strictly validated field a live lane carries
|
||||
/// (`Lane.order`).
|
||||
/// (`modified` is), and it is the same optional-but-always-read field a live lane carries
|
||||
/// (`Lane.order`): an entry that reaches the trash without one reads as append-at-end over the
|
||||
/// container's own entries, which is inert here by construction — a restore computes a fresh
|
||||
/// rank at its destination.
|
||||
public let order: Double
|
||||
|
||||
/// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
|
||||
|
||||
@@ -116,10 +116,33 @@ public enum BoardWriter: Sendable {
|
||||
let operation = operation.withTitle(document.title.value)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
let orderBefore = document.order
|
||||
edits(&document)
|
||||
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
|
||||
// which outrank everything for their own reason.
|
||||
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
|
||||
|
||||
let itemKind = kind ?? derivedKind(ofItemFolder: folder)
|
||||
|
||||
// **The inline sibling stamp** (01-storage-format.md § Ordering, re-ruled 2026-07-31): a
|
||||
// write that rewrites this item's *rank* is placement math landing, and placement math ranks
|
||||
// against the append-at-end reading of any order-less sibling — so those siblings are made to
|
||||
// hold that reading, here, inside the caller's own bracket and commit. Sequence-preserving
|
||||
// and a no-op on every board this app wrote; see `stampAppendAtEndOrders`.
|
||||
//
|
||||
// Two exclusions. `.renumberChildren` *is* the rescale — it rewrites every sibling with a
|
||||
// fresh rank already, and re-entering here would recurse. Anything that is not a lane or a
|
||||
// card has no rank ladder to be placed in: a board root's siblings are other people's
|
||||
// folders, and a comment's are chronology.
|
||||
if document.order != orderBefore, operation != .renumberChildren, itemKind == .lane || itemKind == .card {
|
||||
try stampAppendAtEndOrders(
|
||||
of: folder.deletingLastPathComponent(), excluding: folder, operation: operation)
|
||||
}
|
||||
|
||||
// After `edits`, so a caller that wrote its own `kind` or `order` is left alone, and before
|
||||
// the stamps, which outrank everything for their own reason.
|
||||
IntegrityRules.healOnTouch(
|
||||
&document,
|
||||
kind: itemKind,
|
||||
rank: appendAtEndRank(ofItemFolder: folder, kind: itemKind)
|
||||
)
|
||||
// The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
|
||||
// the container's own arrangement and leaves both provenance keys exactly as it found them —
|
||||
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
|
||||
@@ -166,6 +189,51 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The rank an order-less file reads as** — the on-touch stamp's value (01-storage-format.md
|
||||
/// § Ordering, re-ruled 2026-07-31: "The rank materializes on touch: the first Writer rewrite of
|
||||
/// the file stamps a real rank").
|
||||
///
|
||||
/// It is `Ranks.resolvedOrders`' answer for this folder among its siblings — the very rank the
|
||||
/// board is already rendering it at — so the stamp writes down what the reading already said and
|
||||
/// nothing moves on screen. The loader and this share the function, which is what makes that
|
||||
/// true rather than merely intended.
|
||||
///
|
||||
/// `nil` — stamp nothing — in four cases, each of them "this file has no rank to have":
|
||||
///
|
||||
/// - **The kind has no ladder**: a board root (no siblings), a comment ("Ordering is chronology,
|
||||
/// not ranks"), or a folder whose position has no answer at all.
|
||||
/// - **The sibling scan failed** (an unreadable neighbour, a parse error). An on-touch heal is
|
||||
/// opportunistic by construction — it rides a host write that has its own reason to succeed —
|
||||
/// so a failure here declines to stamp rather than failing that write. The next touch tries
|
||||
/// again.
|
||||
/// - **The item is not among its own visible siblings**: it carries a legacy `deleted:` key, and
|
||||
/// a tombstone is inert to ordering (§ Deletion). The migration relocates it; a rank invented
|
||||
/// on the way would be a fact about a position it no longer has.
|
||||
///
|
||||
/// Never called on a file that already carries a usable `order` — `healOnTouch` takes it as an
|
||||
/// `@autoclosure`, so the directory of reads below is paid for only where it is the answer.
|
||||
private static func appendAtEndRank(
|
||||
ofItemFolder folder: URL,
|
||||
kind: IntegrityRules.ObjectKind?
|
||||
) -> Double? {
|
||||
switch kind {
|
||||
case .lane, .card: break
|
||||
case .board, .comment, nil: return nil
|
||||
}
|
||||
// The operation is only what a failure would be *named*, and this one discards failures.
|
||||
guard let siblings = try? visibleSiblings(
|
||||
of: folder.deletingLastPathComponent(),
|
||||
operation: .renumberChildren,
|
||||
requireEditable: false
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let identity = IntegrityRules.canonicalIdentity(folder.lastPathComponent)
|
||||
return siblings
|
||||
.first { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) == identity }?
|
||||
.order
|
||||
}
|
||||
|
||||
// MARK: - Atomic replace
|
||||
|
||||
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
|
||||
@@ -241,6 +309,11 @@ public enum BoardWriter: Sendable {
|
||||
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
|
||||
/// (§ Document packaging, "Extension-less board folders still open") — this call never
|
||||
/// looks at `rootURL`'s extension.
|
||||
/// - **The board is born with its noise definition** (06-history-undo.md ▸ Repository hygiene,
|
||||
/// re-ruled 2026-07-31: "Board creation writes the minimal seed"): `.gitignore` lands beside
|
||||
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
|
||||
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
|
||||
/// creation into a folder that somehow already carries one leaves it alone.
|
||||
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.createBoard
|
||||
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
||||
@@ -264,6 +337,7 @@ public enum BoardWriter: Sendable {
|
||||
at: indexURL,
|
||||
operation: operation
|
||||
)
|
||||
try seedGitignoreIfAbsent(atBoardRoot: rootURL)
|
||||
}
|
||||
|
||||
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
||||
@@ -308,7 +382,16 @@ public enum BoardWriter: Sendable {
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
||||
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
// **The inline sibling stamp, before the append** (01-storage-format.md § Ordering, re-ruled
|
||||
// 2026-07-31). A create is placement math like any other — "after the current visible
|
||||
// siblings" is a rank *relative to* the last of them — so an order-less sibling has to hold
|
||||
// the rank this append is computed against, or the new item would file itself above a card
|
||||
// that re-materializes past it on the next load. It is the one placement that cannot ride
|
||||
// `updateIndex`'s hook: this path mints a file rather than rewriting one.
|
||||
//
|
||||
// `excluding: nil` — the new identity does not exist yet, so there is nothing to skip, and
|
||||
// the container the stamp reads is exactly the one the append is about to be computed over.
|
||||
let siblings = try stampAppendAtEndOrders(of: parentFolder, excluding: nil, operation: operation)
|
||||
let order = Ranks.append(toVisible: siblings.map(\.order))
|
||||
|
||||
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
||||
@@ -428,6 +511,66 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The board's noise definition
|
||||
|
||||
/// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene, re-ruled
|
||||
/// 2026-07-31: "Board creation writes the minimal seed — `.DS_Store` plus the writer's temp
|
||||
/// pattern (`.*.lanework-*`)").
|
||||
///
|
||||
/// Two lines, and each one earns its place:
|
||||
///
|
||||
/// - **`.DS_Store`** — the Finder writes one into every folder a user looks at, and on a board
|
||||
/// that means one per lane and one per card, each churning as icons and window positions move.
|
||||
/// On a git board it would be committed by the whole-tree stage, forever, under the user's own
|
||||
/// name; on every board it is now also the plainest thing the loose-file heal must not treat as
|
||||
/// a card's attachment.
|
||||
/// - **`.*.lanework-*`** — this file's own temp pattern, and the one entry the app is entitled to
|
||||
/// have an opinion about: `atomicReplace` writes `.<name>.lanework-<uuid>` beside its target,
|
||||
/// so a crashed write's residue is the app's litter rather than the user's file. The two must
|
||||
/// stay in step; changing the temp name means changing this line.
|
||||
///
|
||||
/// Nothing else. "Fine-tuning what counts as noise over time means fine-tuning the seed" — and
|
||||
/// after the seed the file is the user's, never edited by the app again.
|
||||
///
|
||||
/// Files the app creates end with LF (01-storage-format.md § Encoding and line endings).
|
||||
public static let gitignoreSeed = ".DS_Store\n.*.lanework-*\n"
|
||||
|
||||
/// **Puts the seed on a board that has no `.gitignore`, and does nothing to one that has**
|
||||
/// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31: "`.gitignore` seeded on every
|
||||
/// board, never touched after").
|
||||
///
|
||||
/// The three properties are the feature:
|
||||
///
|
||||
/// - **Only when the name is free.** `IntegrityRules.node(at:)` — `lstat`, never `fileExists` —
|
||||
/// so a *directory* or a **dangling symlink** wearing the name is left alone too, rather than
|
||||
/// written through. A board that already carries a `.gitignore` (from a template, a clone, the
|
||||
/// user, or an earlier open) is left byte for byte alone, and an **empty** one is honored
|
||||
/// exactly as it stands: it is the ruling's own escape hatch for wanting no exclusions.
|
||||
/// - **Never an edit.** There is no merge, no append, no "and while we're here". The app writes
|
||||
/// this file at most once per board and then reads it forever.
|
||||
/// - **Universal.** Board creation calls it, template instantiation calls it, and a scheduled
|
||||
/// heal calls it at every open for boards born before the ruling — repo-nested boards included,
|
||||
/// because the file serves the heal there, not any app-managed git.
|
||||
///
|
||||
/// A foreign deletion is answered by re-seeding on the next open, which is what makes the
|
||||
/// scheduled heal's memo (armed before the attempt, cleared on success) load-bearing rather than
|
||||
/// tidy: the picture "missing" must stay healable.
|
||||
///
|
||||
/// - Returns: whether this call wrote the file — `false` when the name was already taken, which
|
||||
/// is a success with nothing to do (the heal's disk re-verify, and the reason a second open
|
||||
/// costs no write).
|
||||
@discardableResult
|
||||
public static func seedGitignoreIfAbsent(atBoardRoot root: URL) throws(BoardWriteError) -> Bool {
|
||||
let url = root.appendingPathComponent(IntegrityRules.gitignoreFileName)
|
||||
guard IntegrityRules.node(at: url) == nil else { return false }
|
||||
try atomicReplace(text: gitignoreSeed, at: url, operation: .seedGitignore)
|
||||
// Heal-marked, on the agent guide's reasoning exactly: this is app-initiated work on a file
|
||||
// the user did not create, and its commit is its own (06-history-undo.md ▸ Commit messages —
|
||||
// a non-snapshot path composes "Update '.gitignore'").
|
||||
EchoLedger.current?.markHeal(at: url)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Renumber
|
||||
|
||||
/// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
|
||||
@@ -442,7 +585,10 @@ public enum BoardWriter: Sendable {
|
||||
/// rewriting the lane.
|
||||
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
|
||||
/// ascending, folder name breaking ties) — the same rule the loader sorts by, so a
|
||||
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
|
||||
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves. An order-less
|
||||
/// sibling takes part on its append-at-end reading (`visibleSiblings`) and comes out of the
|
||||
/// batch with a real rank, which makes the rescale the heavier sibling of
|
||||
/// `stampAppendAtEndOrders` rather than a second rule.
|
||||
/// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no
|
||||
/// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only
|
||||
/// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of
|
||||
@@ -478,20 +624,27 @@ public enum BoardWriter: Sendable {
|
||||
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
|
||||
/// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
|
||||
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
|
||||
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
|
||||
/// that sibling's file, before anything is written — the same discover-before-you-write
|
||||
/// guarantee `renumberVisibleChildren`'s batch depends on.
|
||||
/// - **A missing or unusable `order` is no longer a failure** (01-storage-format.md § Ordering,
|
||||
/// re-ruled 2026-07-31): the sibling reads as append-at-end, exactly as the loader reads it
|
||||
/// (`Ranks.resolvedOrders(of:stored:name:)`), and `order` carries that reading while
|
||||
/// `hasStoredOrder` records whether the file actually says so. The scan is still strict about
|
||||
/// everything it was strict about before — an unreadable or unparseable sibling still fails the
|
||||
/// whole operation before anything is written, which is the discover-before-you-write guarantee
|
||||
/// `renumberVisibleChildren`'s batch depends on.
|
||||
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
|
||||
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
|
||||
/// unwritable sibling halfway through the batch), the creates pass `false` — a create
|
||||
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
|
||||
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
|
||||
/// beside it.
|
||||
///
|
||||
/// The returned ranks are **in listing order, not display order**; every caller that needs the
|
||||
/// latter sorts through `Ranks.sortedForDisplay`, as they always did.
|
||||
private static func visibleSiblings(
|
||||
of parentFolder: URL,
|
||||
operation: WriteOperation,
|
||||
requireEditable: Bool
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
|
||||
let candidates: [URL]
|
||||
do {
|
||||
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
|
||||
@@ -503,7 +656,7 @@ public enum BoardWriter: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
var visible: [(folder: URL, order: Double)] = []
|
||||
var visible: [(folder: URL, storedOrder: Double?)] = []
|
||||
for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
|
||||
@@ -513,25 +666,88 @@ public enum BoardWriter: Sendable {
|
||||
if requireEditable {
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
}
|
||||
visible.append((folder: folder, storedOrder: IntegrityRules.resolvedOrder(in: document).order))
|
||||
}
|
||||
|
||||
switch document.order {
|
||||
case .missing:
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .unreadable(message: "missing required 'order' field")
|
||||
)
|
||||
case let .malformed(raw):
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: indexURL.path,
|
||||
reason: .unreadable(message: "malformed 'order' field: \(raw)")
|
||||
)
|
||||
case let .valid(order):
|
||||
visible.append((folder: folder, order: order))
|
||||
// **The loader's own reading, computed from the same bytes** — which is what lets a placement
|
||||
// the app computed against the *snapshot* be made true on disk by simply stamping it
|
||||
// (`stampAppendAtEndOrders(of:excluding:operation:)`).
|
||||
let resolved = Ranks.resolvedOrders(
|
||||
of: visible, stored: { $0.storedOrder }, name: { $0.folder.lastPathComponent })
|
||||
return zip(visible, resolved).map {
|
||||
(folder: $0.folder, order: $1, hasStoredOrder: $0.storedOrder != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The append-at-end reading's inline stamp
|
||||
|
||||
/// **Writes down the append-at-end reading for every order-less child of `parentFolder`**
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "placement math that must rank an item
|
||||
/// *relative to* an order-less sibling stamps that sibling inline, inside the gesture's bracket
|
||||
/// and commit — the midpoint-renumber precedent").
|
||||
///
|
||||
/// ## Why a placement cannot skip it
|
||||
///
|
||||
/// An order-less file renders at a rank nobody wrote down. Placement math ranks the arriving item
|
||||
/// against that rank — a midpoint between two order-less siblings, an append past the last one —
|
||||
/// and the answer is only *true on disk* if the siblings hold the ranks the math assumed. Without
|
||||
/// the stamp the arriving item would be filed against ranks that evaporate on the next load, and
|
||||
/// the order-less siblings would re-materialize past it: the card lands in the wrong place,
|
||||
/// silently. Stamping is what closes that gap, and it is the same shape as the renumber that
|
||||
/// closes the midpoint-exhaustion gap — inline, inside the caller's write bracket, composing no
|
||||
/// event of its own.
|
||||
///
|
||||
/// ## Why nothing moves when it lands
|
||||
///
|
||||
/// The value written is `Ranks.resolvedOrders`' answer for that file — the rank the board was
|
||||
/// *already rendering it at*. So this is sequence-preserving by construction, exactly like a
|
||||
/// renumber, and it is idempotent: run it twice and the second call finds nothing to do.
|
||||
///
|
||||
/// ## Cost, stated
|
||||
///
|
||||
/// One `index.md` read per visible sibling, on any write that rewrites a rank — the same read the
|
||||
/// reload following that write performs anyway. A board this app wrote has no order-less children
|
||||
/// at all, so the pass writes nothing; it is the hand-filed and agent-filed cases that pay.
|
||||
///
|
||||
/// ## The reading is the container's, and the caller must not narrow it
|
||||
///
|
||||
/// `excluding` skips the **write**, never the reading: the ranks stamped are resolved over the
|
||||
/// container exactly as the loader resolved them, placed item included. Narrowing the resolution
|
||||
/// would re-base the materialized ranks against a different maximum than the snapshot the
|
||||
/// placement was computed from, and the two would disagree about where the order-less siblings
|
||||
/// sit. The corollary is that a placement whose item is **not yet in the container** — a
|
||||
/// cross-parent move, a copy, a create — must stamp *before* the folder lands, so that what this
|
||||
/// reads is what the loader read. Each of those call sites does.
|
||||
///
|
||||
/// - Parameter excluding: the item the caller is about to place, when it is already a child here.
|
||||
/// It is skipped because its own rank is about to be written by the caller anyway, and stamping
|
||||
/// it first would be two writes to one file inside one gesture.
|
||||
/// - Returns: the container's visible siblings and their resolved ranks — the same values a
|
||||
/// re-scan would answer, so a caller that needs them next (the creates' append) can use these
|
||||
/// rather than reading the directory twice.
|
||||
@discardableResult
|
||||
static func stampAppendAtEndOrders(
|
||||
of parentFolder: URL,
|
||||
excluding placed: URL?,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
|
||||
// `requireEditable: false`: only the order-less siblings are rewritten, so only *they* owe
|
||||
// the pre-flight — a flow-mapping sibling that already carries a rank must not block a
|
||||
// placement it has nothing to do with. `updateIndex` refuses an uneditable one loudly, which
|
||||
// is the honest answer for a file the placement genuinely depends on.
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
let excluded = placed.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) }
|
||||
|
||||
for sibling in siblings where !sibling.hasStoredOrder {
|
||||
guard IntegrityRules.canonicalIdentity(sibling.folder.lastPathComponent) != excluded else { continue }
|
||||
// `.renumberChildren`: this *is* a rescale of the container's own arrangement — order-only,
|
||||
// so no `modified` stamp and no `modified-by` clear (the reorders-don't-stamp rule), and
|
||||
// the one operation `updateIndex` does not re-enter this pass for.
|
||||
try updateIndex(inItemFolder: sibling.folder, operation: .renumberChildren) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(sibling.order))
|
||||
}
|
||||
}
|
||||
return visible
|
||||
return siblings
|
||||
}
|
||||
|
||||
// MARK: - Move
|
||||
@@ -655,6 +871,14 @@ public enum BoardWriter: Sendable {
|
||||
return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
|
||||
}
|
||||
|
||||
// **Before the folder lands** (01-storage-format.md § Ordering, re-ruled 2026-07-31): the
|
||||
// destination's order-less children are stamped with the reading they had *without* this
|
||||
// item in the container — which is the reading the caller's placement math was computed
|
||||
// against, since the snapshot it read did not hold this item here either. Doing it after the
|
||||
// move would re-base those ranks against the arriving item's own (foreign-lane) rank.
|
||||
// The same-parent degenerate reorder above needs none of this: it never leaves the container,
|
||||
// so `updateIndex`'s own hook stamps against the unchanged reading.
|
||||
try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
|
||||
@@ -870,6 +1094,10 @@ public enum BoardWriter: Sendable {
|
||||
// contract refuses here, where nothing has been materialized and there is nothing to undo.
|
||||
try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation)
|
||||
|
||||
// `moveItem`'s pre-arrival stamp, for the same reason: the copy is not in the destination's
|
||||
// reading yet, and the placement it is about to take was computed against a reading that did
|
||||
// not hold it (01-storage-format.md § Ordering, re-ruled 2026-07-31).
|
||||
try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
let rootName = freshUUIDName(in: destinationParent, avoiding: [])
|
||||
@@ -2064,13 +2292,33 @@ public enum BoardWriter: Sendable {
|
||||
/// user's own paste made and the app immediately cleaned up.
|
||||
///
|
||||
/// A card with nothing loose is one directory listing and no write at all.
|
||||
///
|
||||
/// **The destination board's noise gate applies here too** (01-storage-format.md § Fractal layout
|
||||
/// ▸ Rules, ruled 2026-07-31): the arriving card is already at its final path, so its files are
|
||||
/// the destination board's to have an opinion about — and a paste that swept a file the very next
|
||||
/// walk would have left alone would be the app disagreeing with itself one gesture apart. The
|
||||
/// rules are read from the board root the card's own path implies (`checkIsCardFolder` proves the
|
||||
/// `<root>/<lane>/<card>` shape before anything moves), which for a batch means one small file
|
||||
/// read per card; the lane-level face below reads it once for the whole lane.
|
||||
@discardableResult
|
||||
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
try normalizeLooseFiles(
|
||||
inCard: cardFolder,
|
||||
ignoring: BoardLoader.ignoreRules(atBoardRoot: boardRoot(ofCard: cardFolder))
|
||||
)
|
||||
}
|
||||
|
||||
/// The rules-in-hand form, so a batch reads the board's `.gitignore` once rather than per card.
|
||||
@discardableResult
|
||||
static func normalizeLooseFiles(
|
||||
inCard cardFolder: URL,
|
||||
ignoring rules: GitignoreRules?
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
// `healMarked: false` — an **inline** heal batches with the gesture that triggered it
|
||||
// (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
|
||||
// heal's, and splitting them out would name a commit for work the user asked for.
|
||||
try relocateLooseFiles(
|
||||
BoardLoader.looseFileNames(in: cardFolder),
|
||||
BoardLoader.looseFileNames(in: cardFolder, ignoring: rules),
|
||||
inCard: cardFolder,
|
||||
healMarked: false
|
||||
)
|
||||
@@ -2084,13 +2332,23 @@ public enum BoardWriter: Sendable {
|
||||
/// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
|
||||
@discardableResult
|
||||
public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
let rules = BoardLoader.ignoreRules(atBoardRoot: laneFolder.deletingLastPathComponent())
|
||||
var moved: [ImportedAttachment] = []
|
||||
for card in childCandidates(of: laneFolder) {
|
||||
moved.append(contentsOf: try normalizeLooseFiles(inCard: card))
|
||||
moved.append(contentsOf: try normalizeLooseFiles(inCard: card, ignoring: rules))
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
/// The board root a card folder implies — `<root>/<lane>/<card>` with two components taken off.
|
||||
///
|
||||
/// Purely arithmetic on the path, and deliberately not a validation: the callers that reach it
|
||||
/// run `checkIsCardFolder` on the same URL, and a folder that is not a card has no board root to
|
||||
/// derive anyway — the rules read from wherever this points would simply find no file.
|
||||
private static func boardRoot(ofCard cardFolder: URL) -> URL {
|
||||
cardFolder.deletingLastPathComponent().deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
|
||||
///
|
||||
/// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
|
||||
@@ -2681,6 +2939,19 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
||||
case agentGuide
|
||||
|
||||
/// The board-root `.gitignore` being seeded — at creation, at instantiation, and by the
|
||||
/// scheduled heal that gives a board born before the ruling the file it now needs
|
||||
/// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31; `seedGitignoreIfAbsent`).
|
||||
///
|
||||
/// **No payload**, for `.agentGuide`'s reason: there is one such file per board, its name is
|
||||
/// fixed, and it is not an item with a title. Its own case beside `.agentGuide` rather than a
|
||||
/// fold into it, on the vocabulary's standing reasoning — one is the agent guide and the other is
|
||||
/// the board's noise definition, and a banner saying the app "couldn't update the agent guide"
|
||||
/// after a failed seed would name a file that was never touched.
|
||||
///
|
||||
/// It never describes an *edit*: the app writes this file only when nothing holds the name.
|
||||
case seedGitignore
|
||||
|
||||
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
||||
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
||||
/// aside by a scheduled heal via the Finder-style rename ladder").
|
||||
@@ -2804,7 +3075,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
|
||||
// filled in by the caller from the window the gesture came from.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
self
|
||||
@@ -2867,7 +3138,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.displaceClaimedName,
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
false
|
||||
@@ -2903,6 +3175,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case .agentGuide: "update the agent guide"
|
||||
case .seedGitignore: "seed the board's .gitignore"
|
||||
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
||||
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
|
||||
@@ -8,10 +8,14 @@ public struct FrontmatterField: Sendable, Equatable {
|
||||
public let rawValue: String
|
||||
}
|
||||
|
||||
/// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`,
|
||||
/// `order`) from being silently coerced — they fail the load instead. Lenient fields (colors,
|
||||
/// icons, `width`) coerce where a sensible reading exists (01-storage-format.md § Frontmatter)
|
||||
/// and only fall back to `.malformed` — rendered as the field's default — when none does.
|
||||
/// The result of reading a typed field — **the document's reading, before any policy**. Lenient
|
||||
/// fields (colors, icons, `width`) coerce where a sensible reading exists (01-storage-format.md
|
||||
/// § Frontmatter) and only fall back to `.malformed` — rendered as the field's default — when none
|
||||
/// does. Structural fields (`schema`, `order`) have no coercion at all at this layer: a value that
|
||||
/// is not an integer, or not a finite number, is `.malformed` and nothing here decides what that
|
||||
/// costs. **What it costs is the rulebook's** (`IntegrityRules`): a malformed `schema` refuses the
|
||||
/// load, while a malformed or missing `order` below the board root reads as append-at-end
|
||||
/// (re-ruled 2026-07-31) — one shape, two policies, and this type carries neither.
|
||||
public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
|
||||
case missing
|
||||
case valid(Value)
|
||||
@@ -76,11 +80,16 @@ extension FrontmatterDocument {
|
||||
/// "the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or
|
||||
/// a notice".
|
||||
///
|
||||
/// **The strict fields are absent, and so is `deleted`.** `schema` and `order` are the *refuse*
|
||||
/// tier — a malformed one fails the load loudly (`IntegrityRules.validatedSchema`/`validatedOrder`),
|
||||
/// so there is no silent recovery to leave a trace of. `deleted` is the odd one out on purpose: its
|
||||
/// rule is *presence, not validity* (a malformed `deleted` still deletes — `Lane`/`Card.isDeleted`),
|
||||
/// so nothing falls back to a default and the migration reports it under its own defect anyway.
|
||||
/// **The structural fields are absent, and so is `deleted`.** `schema` and `order` are read
|
||||
/// through the rulebook rather than here (`IntegrityRules.resolvedSchema`/`resolvedOrder`),
|
||||
/// because their readings depend on something this layer cannot see — *which level the file sits
|
||||
/// at*: the root's `schema` refuses, a lane's or card's defaults to 1, and `order` below the root
|
||||
/// reads as append-at-end (re-ruled 2026-07-31). Those two make their own `CoercedField`s, which
|
||||
/// the loader concatenates with this list; they also cover the shape a pure document reading
|
||||
/// cannot produce at all — an **absent** key, which has no `rawText` to record. `deleted` is the
|
||||
/// odd one out on purpose: its rule is *presence, not validity* (a malformed `deleted` still
|
||||
/// deletes — `Lane`/`Card.isDeleted`), so nothing falls back to a default and the migration
|
||||
/// reports it under its own defect anyway.
|
||||
///
|
||||
/// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case
|
||||
/// and costs one pass over the lenient fields.
|
||||
@@ -103,17 +112,20 @@ extension FrontmatterDocument {
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - Strict (structure — the loader fails fast on `.malformed`)
|
||||
// MARK: - Structural (no coercion here — `IntegrityRules` decides what `.malformed` costs)
|
||||
|
||||
public var schema: FieldValue<Int> {
|
||||
read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } }
|
||||
}
|
||||
|
||||
/// A non-finite reading (`.nan`, `.inf`) has no place in the total order the tie-break and
|
||||
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is the same loud
|
||||
/// malformed-input rejection as a non-numeric value, not a `.valid(Double.nan)` silently
|
||||
/// poisoning every comparison downstream. An `Int` reading is always finite, so only the
|
||||
/// `.double` case needs the check.
|
||||
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) — it is `.malformed` here,
|
||||
/// never a `.valid(Double.nan)` silently poisoning every comparison downstream. An `Int` reading
|
||||
/// is always finite, so only the `.double` case needs the check.
|
||||
///
|
||||
/// Below the board root `.malformed` and `.missing` are one reading — append-at-end, coerce-tier
|
||||
/// (re-ruled 2026-07-31, `IntegrityRules.resolvedOrder`) — but they stay two shapes here, because
|
||||
/// the coerce record wants the text as written and only one of them has any.
|
||||
public var order: FieldValue<Double> {
|
||||
read(FrontmatterKeys.order) { value, _ in
|
||||
switch value {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import Foundation
|
||||
|
||||
/// **The board's noise definition, parsed** — a board-root `.gitignore` read as git itself reads
|
||||
/// one, and asked one question: is this board-relative path ignored?
|
||||
///
|
||||
/// The file outgrew git on 2026-07-31 (01-storage-format.md § Fractal layout ▸ Rules, "`.gitignore`
|
||||
/// is the noise gate"; 06-history-undo.md ▸ Repository hygiene): it is now the one definition of
|
||||
/// noise the **loose-file relocation heal** obeys, on every board, git or not. A file matching it
|
||||
/// keeps the ordinary stray posture — "skipped, preserved verbatim, logged, never relocated, never
|
||||
/// announced" — and "the exclusion list is exactly that file, nothing hardcoded". On a Pro board the
|
||||
/// same file governs the committer through libgit2, so ignored noise neither relocates nor commits:
|
||||
/// one definition, two consumers. This type is the app's half of that, because the load path cannot
|
||||
/// reach for libgit2 — the free tier opens a board without so much as a `fileExists` under `.git`
|
||||
/// (12-editions.md ▸ The free tier and `.git`), and the heal runs on boards that have no repository
|
||||
/// at all.
|
||||
///
|
||||
/// **Pure: no I/O, no filesystem, no `URL`.** It parses text and answers about paths. Reading the
|
||||
/// bytes is `BoardLoader.ignoreRules(atBoardRoot:)`' job, once per walk — the loader stays a pure
|
||||
/// function of the tree, and this stays a pure function of the file.
|
||||
///
|
||||
/// ## The semantics, which are git's
|
||||
///
|
||||
/// Implemented from `gitignore(5)`, in its own order:
|
||||
///
|
||||
/// - A **blank line** matches nothing; it exists to space the file out.
|
||||
/// - A line beginning with `#` is a **comment**. `\#` begins a pattern with a literal `#`.
|
||||
/// - **Trailing spaces are ignored** unless escaped (`\ `).
|
||||
/// - A leading `!` **negates**, re-including a path an earlier pattern excluded. `\!` is a literal.
|
||||
/// - A trailing `/` makes the pattern **directory-only**.
|
||||
/// - A `/` at the beginning or in the middle **anchors** the pattern to the board root; a pattern
|
||||
/// with no interior separator matches **at any depth** (`.DS_Store` matches every one of them).
|
||||
/// - `*` matches any run of characters but never `/`; `?` matches exactly one non-`/` character;
|
||||
/// `[…]` is a character class, negatable with `!` or `^` and carrying ranges (`[0-9]`).
|
||||
/// - A path segment that is exactly `**` matches **zero or more** segments: leading `**/` matches at
|
||||
/// any depth, a trailing `/**` matches everything inside, and `a/**/b` matches `a/b` as well as
|
||||
/// `a/x/y/b`. Asterisks anywhere else are ordinary `*`s, exactly as git says.
|
||||
/// - **Last match wins.** The verdict is the last pattern in file order that matched, negations
|
||||
/// included — which is why the rules are kept in file order and evaluated in it.
|
||||
/// - **An ignored directory is final.** A path under an excluded directory is excluded whatever a
|
||||
/// later negation says about it — git does not descend into an ignored directory, so a rule about
|
||||
/// something inside it is never consulted. `isIgnored(relativePath:isDirectory:)` walks the
|
||||
/// ancestors for exactly this reason.
|
||||
///
|
||||
/// ## The deliberate divergences, and they are three
|
||||
///
|
||||
/// - **Only this file.** Nested `.gitignore` files are never consulted, `.git/info/exclude` and the
|
||||
/// user's global excludes are never read, and `core.excludesFile` does not exist here. That is the
|
||||
/// ruling's own wording — "nested `.gitignore` files are ordinary strays the heal never consults"
|
||||
/// — and it is what makes the noise definition something the user can see in one place.
|
||||
/// - **Case-sensitive matching**, always. Git's is `core.ignorecase`'s to decide, which is a
|
||||
/// repository setting on a file this app reads on boards that have no repository. The board's own
|
||||
/// name comparisons go the other way (`IntegrityRules.reservedCardChildNames` is lowercased
|
||||
/// "because the filesystem this runs on usually is"), but a *pattern* is the user's text and
|
||||
/// folding it would silently widen what they wrote.
|
||||
/// - **No POSIX bracket expressions** (`[[:digit:]]`): a `[` that does not close is a literal `[`,
|
||||
/// and `[:digit:]` reads as the ordinary class it looks like. Nothing realistic in a board's noise
|
||||
/// file uses them, and inventing a second character-class grammar to hold them would be the
|
||||
/// over-engineering this type exists to avoid.
|
||||
public struct GitignoreRules: Sendable, Equatable {
|
||||
|
||||
/// The file's patterns, **in file order** — which is the whole of last-match-wins.
|
||||
private let patterns: [Pattern]
|
||||
|
||||
/// Parses a whole `.gitignore` body.
|
||||
///
|
||||
/// **An empty file parses to no patterns and ignores nothing** — the escape hatch 06 names by
|
||||
/// hand ("the escape hatch for wanting no exclusions is an *empty* file, which the app honors and
|
||||
/// never rewrites"), and the same answer a board carrying no `.gitignore` at all gets. The two
|
||||
/// are deliberately indistinguishable to every consumer: one of them is a statement and the other
|
||||
/// is a silence, but neither excludes anything.
|
||||
///
|
||||
/// Lines are split on **any** newline (`Character.isNewline`), which is git's own reading of a
|
||||
/// CRLF file — `dir.c` terminates each pattern before the `\r` — arrived at from the other
|
||||
/// direction: Swift treats `\r\n` as a single grapheme, so splitting on newline-ness drops the
|
||||
/// carriage return by construction rather than by trimming it afterwards. A board hand-edited on
|
||||
/// Windows must not end up carrying patterns nobody can match. A leading UTF-8 BOM is skipped for
|
||||
/// the reason git skips one: it is a byte-order mark, not the first character of a pattern.
|
||||
public init(parsing text: String) {
|
||||
var body = Substring(text)
|
||||
if body.hasPrefix("\u{FEFF}") { body = body.dropFirst() }
|
||||
patterns = body.split(whereSeparator: \.isNewline).compactMap(Pattern.init(line:))
|
||||
}
|
||||
|
||||
/// Whether the file said anything at all. `true` for a board with no `.gitignore` and for one
|
||||
/// whose `.gitignore` is empty or all comments — the honored-empty state.
|
||||
public var isEmpty: Bool { patterns.isEmpty }
|
||||
|
||||
/// Whether `relativePath` — **board-relative**, `/`-separated, no leading slash (`<lane>/<card>/notes.txt`)
|
||||
/// — is ignored by this file.
|
||||
///
|
||||
/// `isDirectory` decides the directory-only patterns (`build/`) and nothing else. It describes
|
||||
/// what is on disk at that path, which is the caller's to know: the loose-file gate always passes
|
||||
/// `false`, because the carve-out is exactly *files*.
|
||||
///
|
||||
/// **Ancestors are consulted first.** A path inside an excluded directory is excluded, and no
|
||||
/// negation reaches it — git never descends into an ignored directory, so it never sees the rule
|
||||
/// that would have re-included the file. Everything below `build/` is ignored even where the file
|
||||
/// goes on to say `!build/keep.txt`.
|
||||
public func isIgnored(relativePath: String, isDirectory: Bool = false) -> Bool {
|
||||
guard !patterns.isEmpty else { return false }
|
||||
let segments = relativePath.split(separator: "/").map(String.init)
|
||||
guard !segments.isEmpty else { return false }
|
||||
|
||||
for depth in 1 ..< segments.count where verdict(for: Array(segments.prefix(depth)), isDirectory: true) == true {
|
||||
return true
|
||||
}
|
||||
return verdict(for: segments, isDirectory: isDirectory) == true
|
||||
}
|
||||
|
||||
/// The last-match-wins verdict for one path: `true` ignored, `false` explicitly re-included,
|
||||
/// `nil` matched by nothing.
|
||||
///
|
||||
/// `nil` and `false` behave identically at every call site today; they are kept apart because the
|
||||
/// file's grammar keeps them apart — "this was never mentioned" and "this was mentioned and then
|
||||
/// taken back" are different statements, and a future consumer (a listing that shows *why*) would
|
||||
/// need the difference the moment it existed.
|
||||
private func verdict(for segments: [String], isDirectory: Bool) -> Bool? {
|
||||
var answer: Bool?
|
||||
for pattern in patterns {
|
||||
guard !pattern.directoryOnly || isDirectory else { continue }
|
||||
guard pattern.matches(segments) else { continue }
|
||||
answer = !pattern.isNegated
|
||||
}
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One pattern
|
||||
|
||||
extension GitignoreRules {
|
||||
|
||||
/// One line of the file, compiled: what it matches, whether it re-includes, and whether it only
|
||||
/// speaks about directories.
|
||||
///
|
||||
/// **Anchoring is baked into `segments`** rather than carried as a flag: an unanchored pattern is
|
||||
/// exactly its anchored self with a leading `**`, which is what `gitignore(5)` says in prose
|
||||
/// ("the pattern may also match at any level below") and what removes a branch from the matcher.
|
||||
fileprivate struct Pattern: Sendable, Equatable {
|
||||
let segments: [Segment]
|
||||
let isNegated: Bool
|
||||
let directoryOnly: Bool
|
||||
|
||||
/// `nil` for a line that is not a pattern at all — blank, or a comment.
|
||||
init?(line: Substring) {
|
||||
var text = Self.trimmingTrailingSpaces(line)
|
||||
guard !text.isEmpty, text.first != "#" else { return nil }
|
||||
|
||||
if text.first == "!" {
|
||||
isNegated = true
|
||||
text = text.dropFirst()
|
||||
} else {
|
||||
isNegated = false
|
||||
}
|
||||
|
||||
// The trailing separator is the directory-only marker, and it is *not* an interior
|
||||
// separator for the anchoring question below: `foo/` matches a directory named `foo` at
|
||||
// any depth, while `a/b/` is anchored.
|
||||
if text.hasSuffix("/") {
|
||||
directoryOnly = true
|
||||
text = text.dropLast()
|
||||
} else {
|
||||
directoryOnly = false
|
||||
}
|
||||
guard !text.isEmpty else { return nil }
|
||||
|
||||
let anchored = text.contains("/")
|
||||
let parsed = text
|
||||
.split(separator: "/", omittingEmptySubsequences: true)
|
||||
.map(Segment.init(text:))
|
||||
guard !parsed.isEmpty else { return nil }
|
||||
segments = anchored ? parsed : [.globstar] + parsed
|
||||
}
|
||||
|
||||
/// Whether this pattern matches the whole of `path`.
|
||||
func matches(_ path: [String]) -> Bool {
|
||||
Self.match(segments[...], path[...])
|
||||
}
|
||||
|
||||
/// The segment walk, with `**`'s zero-or-more the only place it backtracks.
|
||||
private static func match(_ pattern: ArraySlice<Segment>, _ path: ArraySlice<String>) -> Bool {
|
||||
guard let head = pattern.first else { return path.isEmpty }
|
||||
let tail = pattern.dropFirst()
|
||||
|
||||
if case .globstar = head {
|
||||
// **A trailing `**` matches everything *inside*** (`gitignore(5)`), so it needs a
|
||||
// segment to consume: `a/**` matches `a/b`, never the bare `a`. Where `a` itself is
|
||||
// ignored, some other pattern said so.
|
||||
guard !tail.isEmpty else { return !path.isEmpty }
|
||||
var remaining = path
|
||||
while true {
|
||||
if match(tail, remaining) { return true }
|
||||
guard !remaining.isEmpty else { return false }
|
||||
remaining = remaining.dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
guard case let .literalOrWildcards(tokens) = head,
|
||||
let name = path.first,
|
||||
Segment.match(tokens[...], Array(name)[...])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return match(tail, path.dropFirst())
|
||||
}
|
||||
|
||||
/// Drops the trailing spaces git drops — every one that is not `\`-escaped.
|
||||
///
|
||||
/// Only spaces, and only trailing: git trims exactly this (`trim_trailing_spaces`), so a
|
||||
/// pattern ending in a tab keeps it, and a filename that really does end in a space stays
|
||||
/// reachable by writing `foo\ `.
|
||||
private static func trimmingTrailingSpaces(_ line: Substring) -> Substring {
|
||||
var end = line.endIndex
|
||||
while end > line.startIndex {
|
||||
let previous = line.index(before: end)
|
||||
guard line[previous] == " " else { break }
|
||||
// Escaped when preceded by an odd number of backslashes.
|
||||
var backslashes = 0
|
||||
var scan = previous
|
||||
while scan > line.startIndex {
|
||||
scan = line.index(before: scan)
|
||||
guard line[scan] == "\\" else { break }
|
||||
backslashes += 1
|
||||
}
|
||||
guard backslashes.isMultiple(of: 2) else { break }
|
||||
end = previous
|
||||
}
|
||||
return line[line.startIndex ..< end]
|
||||
}
|
||||
}
|
||||
|
||||
/// One `/`-separated piece of a pattern.
|
||||
fileprivate enum Segment: Sendable, Equatable {
|
||||
/// Exactly `**` — zero or more path segments. "Other consecutive asterisks are considered
|
||||
/// regular asterisks" (`gitignore(5)`), which is why this case is reserved for the whole
|
||||
/// segment and never for a `**` sitting inside one.
|
||||
case globstar
|
||||
/// Everything else, tokenized once at parse time.
|
||||
case literalOrWildcards([Token])
|
||||
|
||||
init(text: Substring) {
|
||||
self = text == "**" ? .globstar : .literalOrWildcards(Token.tokenize(text))
|
||||
}
|
||||
|
||||
/// Matches one path segment against one pattern segment — `*`'s backtracking, iteratively,
|
||||
/// because a pattern is small and a recursion per `*` is not worth the stack.
|
||||
static func match(_ tokens: ArraySlice<Token>, _ name: ArraySlice<Character>) -> Bool {
|
||||
var t = tokens.startIndex
|
||||
var n = name.startIndex
|
||||
// Where to resume from if a `*` guessed short: the star itself and the character it was
|
||||
// last asked to swallow up to.
|
||||
var starToken: Int?
|
||||
var starName = name.startIndex
|
||||
|
||||
while n < name.endIndex {
|
||||
if t < tokens.endIndex {
|
||||
switch tokens[t] {
|
||||
case .anyRun:
|
||||
starToken = t
|
||||
starName = n
|
||||
t += 1
|
||||
continue
|
||||
case .anyCharacter:
|
||||
t += 1
|
||||
n += 1
|
||||
continue
|
||||
case let .literal(character) where character == name[n]:
|
||||
t += 1
|
||||
n += 1
|
||||
continue
|
||||
case let .characterClass(group) where group.matches(name[n]):
|
||||
t += 1
|
||||
n += 1
|
||||
continue
|
||||
case .literal, .characterClass:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let star = starToken else { return false }
|
||||
starName += 1
|
||||
n = starName
|
||||
t = star + 1
|
||||
}
|
||||
|
||||
// Trailing `*`s can still match nothing at all.
|
||||
while t < tokens.endIndex, tokens[t] == .anyRun { t += 1 }
|
||||
return t == tokens.endIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// One element of a pattern segment.
|
||||
fileprivate enum Token: Sendable, Equatable {
|
||||
case literal(Character)
|
||||
/// `?`
|
||||
case anyCharacter
|
||||
/// `*` — any run, `/` excluded by construction (a token never sees a separator).
|
||||
case anyRun
|
||||
/// `[…]`
|
||||
case characterClass(CharacterClass)
|
||||
|
||||
/// Compiles one segment's characters. A `\` escapes whatever follows it; a trailing `\` is a
|
||||
/// literal backslash (there is nothing left for it to escape); an unterminated `[` is a
|
||||
/// literal `[`, which is git's own reading and the only one that cannot lose a character.
|
||||
static func tokenize(_ text: Substring) -> [Token] {
|
||||
var tokens: [Token] = []
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
switch character {
|
||||
case "\\":
|
||||
let next = text.index(after: index)
|
||||
guard next < text.endIndex else {
|
||||
tokens.append(.literal("\\"))
|
||||
index = next
|
||||
continue
|
||||
}
|
||||
tokens.append(.literal(text[next]))
|
||||
index = text.index(after: next)
|
||||
case "?":
|
||||
tokens.append(.anyCharacter)
|
||||
index = text.index(after: index)
|
||||
case "*":
|
||||
// Consecutive asterisks inside a segment are one ordinary `*`.
|
||||
if tokens.last != .anyRun { tokens.append(.anyRun) }
|
||||
index = text.index(after: index)
|
||||
case "[":
|
||||
if let (group, end) = CharacterClass.parse(text, from: index) {
|
||||
tokens.append(.characterClass(group))
|
||||
index = end
|
||||
} else {
|
||||
tokens.append(.literal("["))
|
||||
index = text.index(after: index)
|
||||
}
|
||||
default:
|
||||
tokens.append(.literal(character))
|
||||
index = text.index(after: index)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
|
||||
/// A `[…]` group: members, ranges, and the leading `!`/`^` negation.
|
||||
fileprivate struct CharacterClass: Sendable, Equatable {
|
||||
enum Member: Sendable, Equatable {
|
||||
case single(Character)
|
||||
case range(ClosedRange<Character>)
|
||||
}
|
||||
|
||||
let isNegated: Bool
|
||||
let members: [Member]
|
||||
|
||||
func matches(_ character: Character) -> Bool {
|
||||
let hit = members.contains { member in
|
||||
switch member {
|
||||
case let .single(value): value == character
|
||||
case let .range(range): range.contains(character)
|
||||
}
|
||||
}
|
||||
return hit != isNegated
|
||||
}
|
||||
|
||||
/// Parses from the `[` at `start`, answering the group and the index just past its `]`, or
|
||||
/// `nil` when the group never closes.
|
||||
///
|
||||
/// A `]` **immediately after** the opening bracket (or its negation mark) is a literal member
|
||||
/// rather than the terminator, which is the POSIX rule git inherits — `[]]` matches a bracket.
|
||||
static func parse(_ text: Substring, from start: Substring.Index) -> (CharacterClass, Substring.Index)? {
|
||||
var index = text.index(after: start)
|
||||
var negated = false
|
||||
if index < text.endIndex, text[index] == "!" || text[index] == "^" {
|
||||
negated = true
|
||||
index = text.index(after: index)
|
||||
}
|
||||
|
||||
var members: [Member] = []
|
||||
var first = true
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
if character == "]", !first {
|
||||
return (CharacterClass(isNegated: negated, members: members), text.index(after: index))
|
||||
}
|
||||
first = false
|
||||
|
||||
var value = character
|
||||
if character == "\\" {
|
||||
let next = text.index(after: index)
|
||||
guard next < text.endIndex else { break }
|
||||
value = text[next]
|
||||
index = next
|
||||
}
|
||||
|
||||
// A `-` between two members is a range; one at either end of the group is a literal.
|
||||
let afterValue = text.index(after: index)
|
||||
if afterValue < text.endIndex, text[afterValue] == "-" {
|
||||
let upperIndex = text.index(after: afterValue)
|
||||
if upperIndex < text.endIndex, text[upperIndex] != "]" {
|
||||
var upper = text[upperIndex]
|
||||
var end = upperIndex
|
||||
if upper == "\\" {
|
||||
let escaped = text.index(after: upperIndex)
|
||||
guard escaped < text.endIndex else { break }
|
||||
upper = text[escaped]
|
||||
end = escaped
|
||||
}
|
||||
// An inverted range (`z-a`) is nonsense; git's matcher never matches one, and
|
||||
// `ClosedRange` would trap on it.
|
||||
if value <= upper { members.append(.range(value ... upper)) }
|
||||
index = text.index(after: end)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
members.append(.single(value))
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,13 @@ public enum IntegrityRules: Sendable {
|
||||
/// The file every level's content lives in.
|
||||
public static let indexFileName = "index.md"
|
||||
|
||||
/// **The board's noise definition** (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-31; 06-history-undo.md ▸ Repository hygiene) — a board-root **file** name, claimed
|
||||
/// because the app seeds it on every board and then reads it on every walk: it is the one list of
|
||||
/// files the loose-file relocation heal leaves alone, and on a Pro board the same file is what
|
||||
/// keeps that noise out of history. Seeded once, never edited afterwards.
|
||||
public static let gitignoreFileName = ".gitignore"
|
||||
|
||||
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved
|
||||
/// child, never a level and never identity; the identities are the UUID folders inside it.
|
||||
public static let commentsFolderName = "comments"
|
||||
@@ -180,11 +187,24 @@ public enum IntegrityRules: Sendable {
|
||||
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
|
||||
/// 2026-07-29 — "Lanework owns the board"), or left exactly where it is.
|
||||
///
|
||||
/// `false` for the two names that are *destinations* or not the app's to police:
|
||||
/// `false` for the two names that are *destinations* or that the app can do without:
|
||||
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
|
||||
/// destination by a second displacement would cascade renames (the settled skip stands —
|
||||
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
|
||||
/// (06-history-undo.md ▸ Repository hygiene), and nothing in the app reads it.
|
||||
/// (06-history-undo.md ▸ Repository hygiene).
|
||||
///
|
||||
/// **`.gitignore`'s `false` survived the 2026-07-31 re-ruling deliberately**, and the reason
|
||||
/// changed underneath it. It used to be "nothing in the app reads it", which stopped being
|
||||
/// true the day the file became the loose-file heal's noise gate (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules) — by the level-uniform timing principle (a name "joins the
|
||||
/// scheduled class the day the name becomes load-bearing", which is how `comments` graduated)
|
||||
/// a folder squatting it would now be a candidate. It stays undisplaced because **the app
|
||||
/// degrades to correctness without the file**: an unreadable noise definition means no
|
||||
/// exclusions, which is exactly the behaviour boards had before the ruling and exactly what an
|
||||
/// empty file — the ruling's own escape hatch — asks for. Nothing breaks while the name is
|
||||
/// held, so nothing is displaced; that is the "proactive only when the defect is load-bearing
|
||||
/// *now*" half of § Validation and healing. Neither 01 nor 06 asks for a displacement here,
|
||||
/// and inventing one would move a node of the user's to buy a courtesy file.
|
||||
public let displacesSquatters: Bool
|
||||
}
|
||||
|
||||
@@ -199,7 +219,7 @@ public enum IntegrityRules: Sendable {
|
||||
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
|
||||
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
|
||||
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
|
||||
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
|
||||
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
|
||||
]
|
||||
|
||||
/// **The card-level claimed names** — the same table one level down (01-storage-format.md § Fractal
|
||||
@@ -451,9 +471,15 @@ public enum IntegrityRules: Sendable {
|
||||
|
||||
// MARK: - Per-field validation (the rulebook)
|
||||
|
||||
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
|
||||
/// § Malformed input). Required at every level.
|
||||
public static func validatedSchema(
|
||||
/// `schema` **at the board root**, validated: present, well-formed, not newer than this app
|
||||
/// (01-storage-format.md § Malformed input).
|
||||
///
|
||||
/// **The root keeps its requirement** (re-ruled 2026-07-31): the root's stamp is the board's
|
||||
/// identity claim — the this-really-is-a-board gate — so a root without one is the loud
|
||||
/// rejection it always was, and the decision surface's minted repair (`stamp schema: 1`) is
|
||||
/// what fixes it. Below the root the key is optional and defaults instead
|
||||
/// (`resolvedSchema(in:path:supportedSchema:)`).
|
||||
public static func validatedRootSchema(
|
||||
in document: FrontmatterDocument,
|
||||
path: String,
|
||||
supportedSchema: Int
|
||||
@@ -464,51 +490,111 @@ public enum IntegrityRules: Sendable {
|
||||
case let .malformed(raw):
|
||||
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
||||
case let .valid(value):
|
||||
guard value <= supportedSchema else {
|
||||
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
||||
}
|
||||
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
|
||||
/// board itself — which is the whole of the per-kind difference in the tables today.
|
||||
public static func validatedOrder(
|
||||
in document: FrontmatterDocument,
|
||||
path: String
|
||||
) throws(BoardLoadError) -> Double {
|
||||
switch document.order {
|
||||
case .missing:
|
||||
throw BoardLoadError(path: path, reason: .missingOrder)
|
||||
case let .malformed(raw):
|
||||
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
|
||||
case let .valid(value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather
|
||||
/// than as two hand-written call sites in the loader's walk.
|
||||
/// `schema` **below the board root**, read: a missing (or explicitly null) key reads as **1**
|
||||
/// (01-storage-format.md § Frontmatter, re-ruled 2026-07-31 — "below the root a missing `schema`
|
||||
/// reads as 1 — coerce-tier, logged, bytes preserved — because the walk validating the file
|
||||
/// against schema 1 is exactly what makes the reading reliable").
|
||||
///
|
||||
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
|
||||
/// "**No `title`, no `order`**" — "Ordering is chronology, not ranks", because a conversation's
|
||||
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
|
||||
/// ranks would interleave arbitrarily).
|
||||
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
|
||||
switch kind {
|
||||
case .board, .comment: false
|
||||
case .lane, .card: true
|
||||
/// Two things stay fail-fast here, and both are the same rule they always were:
|
||||
///
|
||||
/// - **A schema newer than the app**, at any level: there is no honest reading of a file written
|
||||
/// by a Lanework that knows keys this one does not, and defaulting would be guessing at content
|
||||
/// rather than recovering it.
|
||||
/// - **A malformed `schema`** — `schema: one`, `schema: [1]`. The ruling made the *absent* key
|
||||
/// optional, not the unreadable one: absence is what a hand-filed item legitimately looks like,
|
||||
/// while a value that is there and unreadable is a claim the app cannot check, and reading it
|
||||
/// as 1 would be inventing agreement.
|
||||
///
|
||||
/// - Returns: the reading, plus the coerce-tier record when one was made — the field, and the
|
||||
/// text as written (`nil` when the key was present and valid, which is every file this app
|
||||
/// ever wrote). The *path* is the loader's to attach (`CoercedFrontmatter`).
|
||||
public static func resolvedSchema(
|
||||
in document: FrontmatterDocument,
|
||||
path: String,
|
||||
supportedSchema: Int
|
||||
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
|
||||
switch document.schema {
|
||||
case .missing:
|
||||
return (defaultSchema, coercedRecord(for: FrontmatterKeys.schema, in: document, raw: nil))
|
||||
case let .malformed(raw):
|
||||
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
||||
case let .valid(value):
|
||||
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
|
||||
return (value, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The reading a `schema`-less file below the root gets — the schema this document *is* being
|
||||
/// validated against, which is the argument for the default rather than a coincidence.
|
||||
public static let defaultSchema = 1
|
||||
|
||||
private static func checkNotNewerThanApp(
|
||||
_ value: Int,
|
||||
path: String,
|
||||
supportedSchema: Int
|
||||
) throws(BoardLoadError) {
|
||||
guard value <= supportedSchema else {
|
||||
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
||||
}
|
||||
}
|
||||
|
||||
/// `order` **below the board root**, read: `nil` where the file carries no rank this app can
|
||||
/// use — which the caller reads as **append-at-end** (`Ranks.resolvedOrders(of:stored:name:)`).
|
||||
///
|
||||
/// **Four shapes, one reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31 —
|
||||
/// "Missing or unusable `order` reads as append-at-end"): a missing key, an explicitly null one
|
||||
/// (`order:` bare, `order: null` — the null-as-missing rule), a non-numeric value, and a
|
||||
/// non-finite one (`.nan`, `.inf`, which has no place in the total order the tie-break and
|
||||
/// midpoint math assume). None of them is a load failure any more; all of them are coerce-tier —
|
||||
/// bytes preserved verbatim, the reading logged, the rank materializing on the file's first
|
||||
/// touch (`healOnTouch`).
|
||||
///
|
||||
/// It never throws, and has no `path`: with nothing left to refuse, there is nothing to name.
|
||||
///
|
||||
/// **The board root never asks.** A board carries no rank — it is not among siblings — so the
|
||||
/// key is meaningless there and this is simply not called on it, exactly as it never was.
|
||||
public static func resolvedOrder(
|
||||
in document: FrontmatterDocument
|
||||
) -> (order: Double?, coerced: CoercedField?) {
|
||||
switch document.order {
|
||||
case .valid(let value):
|
||||
return (value, nil)
|
||||
case .malformed(let raw):
|
||||
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: raw))
|
||||
case .missing:
|
||||
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/// One coerce-tier record: the field and **the value exactly as written**, which for an absent
|
||||
/// key is the empty string — there is no text to record, and inventing a rendering of one would
|
||||
/// defeat the record's whole purpose (a shape observed in the wild, promotable later to a
|
||||
/// heuristic heal). An explicitly-null key answers with its own (empty) source span, so
|
||||
/// "started the key and never gave it a value" is preserved as exactly that.
|
||||
private static func coercedRecord(
|
||||
for key: String,
|
||||
in document: FrontmatterDocument,
|
||||
raw: String?
|
||||
) -> CoercedField {
|
||||
CoercedField(key: key, raw: raw ?? document.rawValue(for: key) ?? "")
|
||||
}
|
||||
|
||||
/// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that
|
||||
/// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per
|
||||
/// kind — board, lane, card, the enhanced schema's comment when it lands").
|
||||
///
|
||||
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
|
||||
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
|
||||
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
|
||||
/// point of the outlet the card validator serves, and the body is free text.
|
||||
/// functions: decode + parse, then `schema` — at the root's strictness for a board, at the
|
||||
/// below-the-root reading for everything else. Nothing further is checked, because nothing else
|
||||
/// *is*: `title` is optional, `order` is optional below the root since 2026-07-31 (a rankless
|
||||
/// card applied through the raw-source outlet lands at its lane's bottom and gains a rank on its
|
||||
/// next touch, exactly as a hand-filed one does), unknown keys are the point of the outlet the
|
||||
/// card validator serves, and the body is free text.
|
||||
///
|
||||
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
|
||||
/// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping
|
||||
@@ -520,9 +606,11 @@ public enum IntegrityRules: Sendable {
|
||||
supportedSchema: Int
|
||||
) throws(BoardLoadError) -> FrontmatterDocument {
|
||||
let document = try BoardLoader.parseDocument(data, path: path)
|
||||
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
if requiresOrder(kind) {
|
||||
_ = try validatedOrder(in: document, path: path)
|
||||
switch kind {
|
||||
case .board:
|
||||
_ = try validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
case .lane, .card, .comment:
|
||||
_ = try resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
return document
|
||||
}
|
||||
@@ -551,6 +639,23 @@ public enum IntegrityRules: Sendable {
|
||||
/// add a key that is redundant with position everywhere else.
|
||||
case kindBackfilled(ObjectKind)
|
||||
|
||||
/// `order` was missing or unusable and has been stamped with the rank the board was
|
||||
/// **already rendering it at** — the append-at-end reading, materialized
|
||||
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "The rank materializes on touch:
|
||||
/// the first Writer rewrite of the file stamps a real rank").
|
||||
///
|
||||
/// Nothing moves on screen when it lands, and that is the design rather than a happy
|
||||
/// accident: the stamped value is `Ranks.resolvedOrders`' own answer for this file, so the
|
||||
/// heal writes down what the reading already said. It is on-touch for the reason the whole
|
||||
/// class is — the defect is latent, the file is being rewritten anyway, and a sweep that
|
||||
/// rewrote a board to add ranks nobody asked for would be churn.
|
||||
///
|
||||
/// **Unlike `kind`, an unusable *present* value is replaced.** `order: banana` and
|
||||
/// `order: .nan` are read as order-less (the value-names-the-kind posture has no twin here:
|
||||
/// a rank must be a number for the midpoint math to mean anything), so the stamp is the same
|
||||
/// heal in both cases and the unreadable text does not survive the rewrite.
|
||||
case rankStamped(Double)
|
||||
|
||||
/// A key written twice collapsed to its winning (last) occurrence — the span editor's
|
||||
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
|
||||
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
|
||||
@@ -569,28 +674,43 @@ public enum IntegrityRules: Sendable {
|
||||
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
||||
/// rewriting").
|
||||
///
|
||||
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
|
||||
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
|
||||
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
|
||||
/// change".
|
||||
/// Two of the four members are applied *here* — the `kind` backfill and the rank stamp; the
|
||||
/// other two are applied by `FrontmatterDocument`'s own editor on every key it writes, and are
|
||||
/// named in `OnTouchHeal` rather than re-implemented. That is the honest shape of "the same
|
||||
/// class, named, no behavior change".
|
||||
///
|
||||
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
|
||||
/// stripped — the value names the kind and consumers trust it outright. An explicit `kind:`
|
||||
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
|
||||
/// and so backfills.
|
||||
/// - **`kind`: missing only.** A present `kind` is never rewritten, never corroborated, and
|
||||
/// never stripped — the value names the kind and consumers trust it outright. An explicit
|
||||
/// `kind:` with nothing after it reads as missing, like every other null (the null-as-missing
|
||||
/// rule), and so backfills.
|
||||
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
|
||||
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
|
||||
/// trusts what it finds.
|
||||
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
|
||||
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
|
||||
/// - **`order`: missing *or unusable*.** The reading is stated over usability
|
||||
/// (`resolvedOrder(in:)`), so the stamp is too.
|
||||
/// - **`rank == nil` stamps nothing** — the board root (which has no siblings and no rank), a
|
||||
/// comment (which never gains one — "Ordering is chronology, not ranks"), and any folder whose
|
||||
/// position has no answer. The same posture as `kind`'s: a rank invented for something that is
|
||||
/// not a lane or a card would be a fact nobody asked for.
|
||||
/// - Parameters kind, rank: `@autoclosure` so a caller whose answer costs a directory listing
|
||||
/// (the trash's shape fallback; the rank's own sibling scan) pays for it only on a file that
|
||||
/// actually needs the heal — which, on a board this app wrote, is never.
|
||||
@discardableResult
|
||||
public static func healOnTouch(
|
||||
_ document: inout FrontmatterDocument,
|
||||
kind: @autoclosure () -> ObjectKind?
|
||||
kind: @autoclosure () -> ObjectKind?,
|
||||
rank: @autoclosure () -> Double? = nil
|
||||
) -> [OnTouchHeal] {
|
||||
guard document.kind.isMissing, let kind = kind() else { return [] }
|
||||
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||
return [.kindBackfilled(kind)]
|
||||
var applied: [OnTouchHeal] = []
|
||||
if document.kind.isMissing, let kind = kind() {
|
||||
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||
applied.append(.kindBackfilled(kind))
|
||||
}
|
||||
if document.order.value == nil, let rank = rank() {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
applied.append(.rankStamped(rank))
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// MARK: - The typed defect stream
|
||||
@@ -640,6 +760,12 @@ public enum IntegrityRules: Sendable {
|
||||
/// It is a class here because the engine treats it exactly like the others — same gates,
|
||||
/// same memo, same clear-on-success.
|
||||
///
|
||||
/// `missingGitignore` is the third, and the guide's twin in every respect: the board's noise
|
||||
/// definition is one board-root file's presence, read at the moment of healing
|
||||
/// (`BoardWriter.seedGitignoreIfAbsent`) rather than reported by a tree walk — "a board
|
||||
/// missing the file gains it by scheduled heal at open (the guide-refresh cadence)"
|
||||
/// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31).
|
||||
///
|
||||
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
|
||||
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
|
||||
/// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the
|
||||
@@ -652,6 +778,7 @@ public enum IntegrityRules: Sendable {
|
||||
case claimedNameSquatted
|
||||
case duplicateIdentity
|
||||
case staleAgentGuide
|
||||
case missingGitignore
|
||||
case commentTrashResidue
|
||||
}
|
||||
|
||||
|
||||
@@ -119,11 +119,63 @@ enum Ranks: Sendable {
|
||||
return (1...count).map { Double($0) * gap }
|
||||
}
|
||||
|
||||
// MARK: - The append-at-end reading
|
||||
|
||||
/// **Every sibling's rank, with the order-less ones read as append-at-end** — the whole of the
|
||||
/// optional-`order` ruling's read side (01-storage-format.md § Ordering, re-ruled 2026-07-31:
|
||||
/// "Missing or unusable `order` reads as append-at-end … order-less items sort after every
|
||||
/// ordered sibling, among themselves by the folder-name tie-break — deterministic with zero
|
||||
/// sibling reads, which is what makes the minimum agent card legal").
|
||||
///
|
||||
/// - Parameter stored: the `order` the file actually carries, or `nil` where it carries none the
|
||||
/// loader can use — missing, explicitly null, non-numeric, non-finite. The four shapes are one
|
||||
/// answer here on purpose: the reading is stated over *usability*, not over which way a value
|
||||
/// failed to be usable (`IntegrityRules.resolvedOrder` is where the four are told apart, for
|
||||
/// the coerce-tier record).
|
||||
/// - Parameter name: the folder name — the tie-break the design states this ordering in.
|
||||
/// - Returns: one rank per sibling, **positionally aligned with `siblings`** (never reordered:
|
||||
/// callers sort afterwards, through `sortedForDisplay`, exactly as they always did).
|
||||
///
|
||||
/// **The materialized ranks are `append`'s own arithmetic**, and that is the load-bearing
|
||||
/// property rather than a convenience: the k-th order-less sibling reads as `max + 1024·k` over
|
||||
/// the ranks actually written down, which is exactly where `append(toVisible:)` would have put it
|
||||
/// had it been filed by the app. So the reading a board *renders* is a rank ladder the Writer can
|
||||
/// stamp verbatim — which is what the on-touch and inline stamps do
|
||||
/// (`BoardWriter.stampAppendAtEndOrders`), and why stamping one changes nothing on screen.
|
||||
///
|
||||
/// An empty ranked set bases at `0`, so a container of nothing but order-less items reads
|
||||
/// `1024, 2048, …` — the same board convention `append` gives an empty container's first child.
|
||||
///
|
||||
/// **Accepted cost, stated by the ruling**: two order-less siblings sort by folder name rather
|
||||
/// than by intent until something touches them.
|
||||
static func resolvedOrders<T>(
|
||||
of siblings: [T],
|
||||
stored: (T) -> Double?,
|
||||
name: (T) -> String
|
||||
) -> [Double] {
|
||||
let storedOrders = siblings.map(stored)
|
||||
var resolved = storedOrders.map { $0 ?? 0 }
|
||||
let orderless = storedOrders.indices.filter { storedOrders[$0] == nil }
|
||||
guard !orderless.isEmpty else { return resolved }
|
||||
|
||||
let base = storedOrders.compactMap { $0 }.max() ?? 0
|
||||
let queue = orderless.sorted { name(siblings[$0]) < name(siblings[$1]) }
|
||||
for (step, index) in queue.enumerated() {
|
||||
resolved[index] = base + gap * Double(step + 1)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Display order
|
||||
|
||||
/// Ascending display order: primary key `order`, ties broken by folder
|
||||
/// name (lexicographic) for deterministic rendering. Shared by the
|
||||
/// loader and the writer so both apply the same tie-break rule.
|
||||
///
|
||||
/// An order-less sibling reaches here already carrying its append-at-end
|
||||
/// reading (`resolvedOrders(of:stored:name:)`), so this comparator needs no
|
||||
/// case for one: "after every ordered sibling, then by folder name" *is*
|
||||
/// this rule applied to the materialized ranks.
|
||||
static func isOrderedForDisplay<T>(
|
||||
_ lhs: T, before rhs: T,
|
||||
order: (T) -> Double, name: (T) -> String
|
||||
|
||||
Reference in New Issue
Block a user