The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
837 lines
47 KiB
Swift
837 lines
47 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure
|
|
/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal
|
|
/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input)
|
|
/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent
|
|
/// partial result.
|
|
///
|
|
/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders →
|
|
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸
|
|
/// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any
|
|
/// version** — is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly
|
|
/// what's checked.
|
|
/// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped
|
|
/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended
|
|
/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a
|
|
/// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older
|
|
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
|
|
///
|
|
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules), and cards are leaves *structurally*: the
|
|
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
|
|
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
|
|
/// levels, so they never need special-casing against the stray warning.
|
|
///
|
|
/// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither
|
|
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
|
|
///
|
|
/// - `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
|
|
/// `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
|
|
/// (`BoardStore.relocateLooseCardFiles`).
|
|
///
|
|
/// Everything else about a card folder's contents remains outside this loader's business.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// ## The trash is a container, not a level
|
|
///
|
|
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly
|
|
/// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no
|
|
/// `index.md` of its own". The walk therefore treats it as a second card container beside the
|
|
/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane
|
|
/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands
|
|
/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and
|
|
/// never warns; absent, the trash is simply empty.
|
|
///
|
|
/// ## The migration window
|
|
///
|
|
/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on
|
|
/// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane
|
|
/// returns live with the key removed, a board-level key stays meaningless (ignored + logged).
|
|
/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports
|
|
/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated
|
|
/// fix.
|
|
///
|
|
/// **Until that write lands, such items still load through the retiring tombstone path** —
|
|
/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a
|
|
/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy",
|
|
/// and the safe direction while the fix is still pending (it is deferred under any read-only lock,
|
|
/// and can be pending for a whole session) is for nothing to silently vanish from view before its
|
|
/// folder has actually moved. The window closes per board on the first successful migration write,
|
|
/// after which no `deleted:` key exists to read and the flag is permanently `false`.
|
|
public enum BoardLoader: Sendable {
|
|
|
|
/// Schema version this app understands; anything higher fails fast
|
|
/// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
|
|
/// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
|
|
/// stamps into files it creates — one symbol, so the app can never write a file its own
|
|
/// loader would reject as newer-than-supported.
|
|
static let supportedSchema = 1
|
|
|
|
/// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
|
|
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
|
|
/// than through a typed `FrontmatterDocument` accessor.
|
|
///
|
|
/// Internal rather than `private` for `indexFileName`'s reason: `TemplateEngine` writes this
|
|
/// key on a Save as Template copy — "the one writer of keyed files is Save as Template"
|
|
/// (09-templates.md ▸ Storage) — and the reader and that one writer must never disagree about
|
|
/// how it is spelled.
|
|
static let templateKey = "template"
|
|
|
|
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
|
|
/// the writer must never disagree about which file a folder's content lives in.
|
|
///
|
|
/// The name itself is `IntegrityRules`', with every other reserved name — one table
|
|
/// (02-architecture.md ▸ Components).
|
|
static let indexFileName = IntegrityRules.indexFileName
|
|
|
|
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
|
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
|
/// seeded `.gitignore` on the claimed list.
|
|
///
|
|
/// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
|
|
/// skips hidden entries, so the container can never be mistaken for a lane candidate and can
|
|
/// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
|
|
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
|
/// name, and a board can have only one trash. The name is `IntegrityRules`', with the rest of
|
|
/// the claimed-name table.
|
|
static let trashFolderName = IntegrityRules.trashFolderName
|
|
|
|
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
|
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
|
/// app-claimed, not strays", plus `.trash/` from § Deletion).
|
|
///
|
|
/// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
|
|
/// files are listed because the claim is about names, and a future check that needs the set
|
|
/// should find it complete rather than build a second one. Compared lowercased, like
|
|
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
|
///
|
|
/// The table is `IntegrityRules.claimedRootNames`, which also carries what kind of node each
|
|
/// name is allowed to be — the fact the squatter-displacement heal turns on (ruled 2026-07-29).
|
|
static let reservedRootNames: Set<String> = IntegrityRules.claimedRootNameSet
|
|
|
|
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
|
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
|
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
|
|
/// listed because the schema reserves the name, not because anything writes it yet —
|
|
/// `attachments/` is still the one folder this app ever creates under a card.
|
|
///
|
|
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
|
|
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
|
|
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
|
|
/// names on its own, so a caller passing a hand-made list cannot reach past this rule. The
|
|
/// table itself is `IntegrityRules`', with every other reserved name.
|
|
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
|
|
|
// MARK: - Entry point
|
|
|
|
public static func load(boardRoot: URL) throws(BoardLoadError) -> LoadResult {
|
|
try checkIsReadableDirectory(boardRoot)
|
|
|
|
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
|
|
guard FileManager.default.fileExists(atPath: boardIndexURL.path) else {
|
|
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
|
|
}
|
|
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
|
|
let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName)
|
|
|
|
var warnings: [LoadWarning] = []
|
|
func warn(_ warning: LoadWarning) {
|
|
warnings.append(warning)
|
|
logger.warning("\(warning.description, privacy: .public)")
|
|
}
|
|
|
|
// **The one typed defect stream** (02-architecture.md ▸ Components ▸ IntegrityRules): what
|
|
// this walk found that is pending *work*, as distinct from `warnings`, which is the
|
|
// stray-*tolerance* vocabulary — information, not work. The two ad-hoc repair channels this
|
|
// replaced (loose files, legacy tombstones) are still readable under their own names as
|
|
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
|
|
var defects: [IntegrityRules.Defect] = []
|
|
|
|
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
|
|
// the trash read below finds nothing to parse. Read-only here, like every other detection:
|
|
// the displacement is the store's, through the Writer (the Repair precedent).
|
|
if let squatter = IntegrityRules.squattedClaimedName(atBoardRoot: boardRoot) {
|
|
defects.append(.claimedNameSquatted(squatter))
|
|
logger.warning(
|
|
"\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
|
)
|
|
}
|
|
|
|
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
|
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
|
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
|
|
// deliberately absent from `legacyTombstones`: there is no item to relocate and no key
|
|
// the app has any business removing from a file it was told to leave alone.
|
|
if !boardDocument.deleted.isMissing {
|
|
warn(.boardLevelDeletedIgnored)
|
|
}
|
|
|
|
var lanes: [Lane] = []
|
|
for laneURL in try directoryCandidates(in: boardRoot) {
|
|
let laneName = laneURL.lastPathComponent
|
|
// The app-claimed board-root names are not strays and must not warn as such. Hidden
|
|
// entries never reach here anyway (`.trash` included), so this is the rule stated
|
|
// rather than the mechanism relied on.
|
|
guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
|
|
guard isUUIDShaped(laneName) else {
|
|
warn(.nonUUIDFolderIgnored(path: laneName))
|
|
continue
|
|
}
|
|
guard hasIndex(laneURL) else {
|
|
warn(.missingIndex(path: laneName))
|
|
continue
|
|
}
|
|
|
|
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)
|
|
|
|
var cards: [Card] = []
|
|
for cardURL in try directoryCandidates(in: laneURL) {
|
|
let cardName = cardURL.lastPathComponent
|
|
let cardRelPath = laneName + "/" + cardName
|
|
guard isUUIDShaped(cardName) else {
|
|
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
|
continue
|
|
}
|
|
guard hasIndex(cardURL) else {
|
|
warn(.missingIndex(path: cardRelPath))
|
|
continue
|
|
}
|
|
|
|
let card = try parseCard(at: cardURL, path: cardRelPath)
|
|
|
|
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
|
let loose = looseFileNames(in: cardURL)
|
|
if !loose.isEmpty {
|
|
defects.append(.looseCardFiles(LooseCardFiles(
|
|
laneID: ItemID(rawValue: laneName),
|
|
cardID: ItemID(rawValue: cardName),
|
|
title: card.title.value,
|
|
fileNames: loose
|
|
)))
|
|
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
|
}
|
|
|
|
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
|
// and the key's removal are the store's, through the Writer.
|
|
if card.isDeleted {
|
|
defects.append(.legacyTombstone(LegacyTombstone(
|
|
kind: .card,
|
|
laneID: ItemID(rawValue: laneName),
|
|
cardID: ItemID(rawValue: cardName),
|
|
title: card.title.value
|
|
)))
|
|
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
|
}
|
|
|
|
cards.append(card)
|
|
}
|
|
|
|
if !laneDocument.deleted.isMissing {
|
|
defects.append(.legacyTombstone(LegacyTombstone(
|
|
kind: .lane,
|
|
laneID: ItemID(rawValue: laneName),
|
|
cardID: nil,
|
|
title: laneDocument.title.value
|
|
)))
|
|
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
|
}
|
|
|
|
lanes.append(Lane(
|
|
id: ItemID(rawValue: laneName),
|
|
schema: laneSchema,
|
|
title: laneDocument.title,
|
|
created: laneDocument.created,
|
|
modified: laneDocument.modified,
|
|
modifiedBy: laneDocument.modifiedBy,
|
|
deleted: laneDocument.deleted,
|
|
background: laneDocument.background,
|
|
icon: laneDocument.icon,
|
|
iconColor: laneDocument.iconColor,
|
|
order: laneOrder,
|
|
width: laneDocument.width,
|
|
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }),
|
|
document: laneDocument
|
|
))
|
|
}
|
|
|
|
var trash: [Card] = []
|
|
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
|
for cardURL in trashCandidates(in: boardRoot) {
|
|
let cardName = cardURL.lastPathComponent
|
|
let cardRelPath = trashFolderName + "/" + cardName
|
|
guard isUUIDShaped(cardName) else {
|
|
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
|
continue
|
|
}
|
|
guard hasIndex(cardURL) else {
|
|
warn(.missingIndex(path: cardRelPath))
|
|
continue
|
|
}
|
|
let entry = try parseCard(at: cardURL, path: cardRelPath)
|
|
// **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
|
|
// outright, and only an unrecognized value or no key at all falls through to shape.
|
|
// Reading the shape half is one directory listing, and only when the value did not
|
|
// answer — see `looksLikeALaneFolder(_:)`.
|
|
trashKinds[entry.id] = IntegrityRules.trashKind(
|
|
kindValue: entry.document.kind.value,
|
|
hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL)
|
|
)
|
|
trash.append(entry)
|
|
}
|
|
|
|
let model = BoardModel(
|
|
rootURL: boardRoot,
|
|
schema: boardSchema,
|
|
title: boardDocument.title,
|
|
created: boardDocument.created,
|
|
modified: boardDocument.modified,
|
|
modifiedBy: boardDocument.modifiedBy,
|
|
deleted: boardDocument.deleted,
|
|
background: boardDocument.background,
|
|
icon: boardDocument.icon,
|
|
iconColor: boardDocument.iconColor,
|
|
template: boardDocument.value(for: templateKey),
|
|
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
|
|
trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }),
|
|
document: boardDocument
|
|
)
|
|
|
|
return LoadResult(
|
|
model: model,
|
|
warnings: warnings,
|
|
defects: defects,
|
|
trashKinds: trashKinds
|
|
)
|
|
}
|
|
|
|
/// Whether a folder in `.trash/` has the *shape* of a lane — at least one identity-shaped child
|
|
/// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
|
|
/// their own `index.md` → lane … else card").
|
|
///
|
|
/// Only reached when `kind` did not answer (`IntegrityRules.trashKind`'s `@autoclosure`), and
|
|
/// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside
|
|
/// it would load. A trashed lane's cards are never enumerated as levels — the walk stops at a
|
|
/// trash entry exactly as it stops at a card under a lane.
|
|
private static func looksLikeALaneFolder(_ folder: URL) -> Bool {
|
|
let children = (try? directoryCandidates(in: folder)) ?? []
|
|
return children.contains { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
|
}
|
|
|
|
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
|
///
|
|
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
|
/// function is what makes that literally true rather than a claim two code paths have to keep
|
|
/// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the
|
|
/// same verbatim document. Its callers keep what genuinely differs by container — the
|
|
/// lane-keyed loose-file and legacy-tombstone channels — outside it.
|
|
///
|
|
/// `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 {
|
|
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)
|
|
|
|
return Card(
|
|
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,
|
|
attachments: attachmentNames(in: cardURL),
|
|
document: document
|
|
)
|
|
}
|
|
|
|
/// The candidate card folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
|
///
|
|
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
|
|
/// boards never have one, and a board without a trash is a board with an empty trash.
|
|
///
|
|
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
|
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
|
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
|
/// reports. Not a `LoadWarning`: that is the stray vocabulary, and a claimed name is not a
|
|
/// stray. Since 2026-07-29 it is not merely logged either — the walk reports it as a
|
|
/// `Defect.claimedNameSquatted` (detected up in `load`, before the lanes) and a scheduled heal
|
|
/// displaces it. Until that heal lands the loader keeps this empty-trash read, which is exactly
|
|
/// the "window measured in one reload, not a standing state" the ruling accepts.
|
|
///
|
|
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
|
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
|
/// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a
|
|
/// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are
|
|
/// never enumerated, because the walk stops at a card exactly as it does under a lane; the
|
|
/// outer folder either parses as a card (it has an `index.md`) or is skipped as
|
|
/// `.missingIndex`, and either way nothing below it renders.
|
|
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
|
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
|
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
|
return []
|
|
}
|
|
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
|
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash until the heal displaces it")
|
|
return []
|
|
}
|
|
return (try? directoryCandidates(in: trashURL)) ?? []
|
|
}
|
|
|
|
// MARK: - Filesystem helpers
|
|
|
|
private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) {
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
|
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))
|
|
}
|
|
guard isDirectory.boolValue else {
|
|
throw BoardLoadError(path: ".", reason: .notADirectory)
|
|
}
|
|
}
|
|
|
|
private static func hasIndex(_ folder: URL) -> Bool {
|
|
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
|
|
}
|
|
|
|
/// The names of `<card>/attachments/`'s **top-level regular files** — the flat view
|
|
/// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
|
|
/// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`.
|
|
///
|
|
/// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
|
|
/// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
|
|
/// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
|
|
/// never appears as an attachment), and symlinks (this loader resolves nothing — the same
|
|
/// stance the level walk takes).
|
|
///
|
|
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
|
|
/// `"shot 10.png"`: the order has to be stable across loads, and where it is already the
|
|
/// sidebar's order it may as well be the same one.
|
|
///
|
|
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
|
|
/// structure (01-storage-format.md § Malformed input), and this field decorates a card — a
|
|
/// permissions race here must never be the reason a whole board refuses to open.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's
|
|
/// authoritative listing — answers through this same function behind its own card-folder
|
|
/// guard, so the face and the sidebar can never disagree about what a card's attachments are.
|
|
/// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
|
|
/// the app ever creates under a card.
|
|
static func attachmentNames(in cardFolder: URL) -> [String] {
|
|
let folder = cardFolder.appendingPathComponent(
|
|
BoardWriter.attachmentsFolderName, isDirectory: true
|
|
)
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: folder,
|
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return entries
|
|
.filter { url in
|
|
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
|
return false
|
|
}
|
|
return values.isRegularFile == true && values.isSymbolicLink != true
|
|
}
|
|
.map(\.lastPathComponent)
|
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
/// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"):
|
|
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
|
|
/// belongs in `attachments/`, and the app moves it there".
|
|
///
|
|
/// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
|
|
/// 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:
|
|
///
|
|
/// - **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
|
|
/// into the flat attachment model would be wrong".
|
|
/// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk
|
|
/// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
|
|
/// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
|
|
/// own account.
|
|
/// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
|
|
/// 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.
|
|
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
|
|
///
|
|
/// 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] {
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: cardFolder,
|
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return entries
|
|
.filter { url in
|
|
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
|
|
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
|
else {
|
|
return false
|
|
}
|
|
return values.isRegularFile == true && values.isSymbolicLink != true
|
|
}
|
|
.map(\.lastPathComponent)
|
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
/// 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
|
|
/// **shape-only**: lowercase v4 is the app's emission rule, not the gate.
|
|
///
|
|
/// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*,
|
|
/// so a strict lowercase gate would turn an agent's standard-tool card into a silently
|
|
/// skipped stray — the worst failure mode for a files-first app. Accept liberally, emit
|
|
/// conservatively: `BoardWriter` still writes only lowercase v4 and never renames an
|
|
/// existing folder to canonicalize it.
|
|
/// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are
|
|
/// **not** validated: they protect no invariant here — an agent's v7 is exactly as unique
|
|
/// as a v4 — and the loader's job is recognizing the folder-naming *convention*, not
|
|
/// re-deriving RFC 4122 conformance every load.
|
|
///
|
|
/// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md
|
|
/// states the rule; kept as a manual scan because that is the cheaper answer on the hot path
|
|
/// (every folder of every load) and needs no bridging.
|
|
///
|
|
/// Recognizing a name is not the same as *comparing* two of them: identity comparison is
|
|
/// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere —
|
|
/// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares
|
|
/// canonically.
|
|
///
|
|
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
|
|
/// candidates the loader walked, and level detection has to be one rule, not two.
|
|
///
|
|
/// **The rule itself is `IntegrityRules.isIdentityShaped`** (settled 2026-07-29 — the one
|
|
/// vocabulary of object validity). This is the loader's spelling of it and nothing more: one
|
|
/// predicate, one implementation, no parallel derivation.
|
|
static func isUUIDShaped(_ name: String) -> Bool {
|
|
IntegrityRules.isIdentityShaped(name)
|
|
}
|
|
|
|
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
|
|
/// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
|
|
/// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded
|
|
/// here too: only directories are level candidates at all, and the caller further narrows
|
|
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
|
|
/// anything else with them.
|
|
///
|
|
/// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is
|
|
/// exactly right: it is a container, not a level, and the walk reaches it by name instead
|
|
/// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
|
|
/// children get identical stray tolerance.
|
|
///
|
|
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
|
|
/// candidates" rather than failing the whole load — fail-fast is reserved for the board
|
|
/// root and for malformed `index.md` content, not transient directory-listing races below
|
|
/// it.
|
|
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates
|
|
/// siblings through this same door, so the writer's idea of "the children" can never drift
|
|
/// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the
|
|
/// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load.
|
|
static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] {
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: folder,
|
|
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return entries
|
|
.filter { url in
|
|
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
|
return false
|
|
}
|
|
return values.isDirectory == true && values.isSymbolicLink != true
|
|
}
|
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
|
}
|
|
|
|
// MARK: - Document reading + field validation
|
|
|
|
/// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same
|
|
/// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a
|
|
/// leading BOM, which would let a BOM'd file *load* here and then refuse every write over
|
|
/// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is
|
|
/// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
|
|
/// decoding byte-faithfully is what makes that stated rejection actually happen.
|
|
private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: url)
|
|
} catch {
|
|
throw BoardLoadError(
|
|
path: path,
|
|
reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
|
|
)
|
|
}
|
|
return try parseDocument(data, path: path)
|
|
}
|
|
|
|
/// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL.
|
|
///
|
|
/// Split out for the raw-source outlet, which validates bytes that are **not on disk yet**
|
|
/// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the
|
|
/// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if
|
|
/// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd
|
|
/// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules).
|
|
static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
|
guard let text = String(validating: data, as: UTF8.self) else {
|
|
throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
|
|
}
|
|
|
|
do {
|
|
return try FrontmatterDocument.parse(text)
|
|
} catch {
|
|
let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil }
|
|
throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line))
|
|
}
|
|
}
|
|
|
|
/// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate
|
|
/// (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.
|
|
///
|
|
/// 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
|
|
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
|
|
///
|
|
/// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text
|
|
/// (line numbers included) rather than a re-worded copy.
|
|
///
|
|
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
|
|
/// what the card window's alert is about.
|
|
///
|
|
/// **The rule is `IntegrityRules.validateIndex(_:path:kind:supportedSchema:)`**, generalized per
|
|
/// kind (02-architecture.md ▸ Components). This spelling stays because it is what the card
|
|
/// window asks — "would this load as a card?" — and because pinning the kind at the call site is
|
|
/// what keeps the outlet's gate from drifting when a second kind gains one.
|
|
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
|
try IntegrityRules.validateIndex(data, path: path, kind: .card, supportedSchema: supportedSchema)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
|
|
try IntegrityRules.validatedOrder(in: document, path: path)
|
|
}
|
|
}
|
|
|
|
// MARK: - Result
|
|
|
|
/// A successful load: the snapshot, anything tolerated-but-notable encountered along the way, and
|
|
/// the pending work the walk found. `warnings` is also logged as it accumulates
|
|
/// (`os.Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")`) so it shows up in Console
|
|
/// even if a caller never inspects it.
|
|
public struct LoadResult: Sendable {
|
|
public var model: BoardModel
|
|
public var warnings: [LoadWarning]
|
|
|
|
/// **The typed defect stream** — everything this walk found that is pending *work*
|
|
/// (02-architecture.md ▸ Components ▸ IntegrityRules, settled 2026-07-29). One channel, not
|
|
/// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
|
|
/// wrong kind of node all classify as `IntegrityRules.Defect`, carry their own signature, and
|
|
/// are healed by the one engine (`HealScheduler`).
|
|
///
|
|
/// **Deliberately not `warnings`**, which stays the *tolerate*-tier vocabulary: "this was
|
|
/// ignored, it is staying exactly where it is, there is nothing to do". A defect says the
|
|
/// opposite — it is work, and the store acts on it. Folding the two would also throw away
|
|
/// everything a heal needs (which lane, which card, which title, which names) and force it to be
|
|
/// re-derived from a display string.
|
|
///
|
|
/// Nothing renders this. Order is the walk's: the board root's claimed names, then, lane by
|
|
/// lane, each lane's cards and then the lane itself.
|
|
///
|
|
/// **Cards in `.trash/` are deliberately not walked for loose files in this version.** The
|
|
/// loose-file defect is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a
|
|
/// trashed card has no lane; widening the key is the store-side change that belongs with the
|
|
/// store-side scheduling. Loose files beside a trashed card's `index.md` therefore keep the
|
|
/// ordinary stray posture — tolerated, preserved verbatim — and are tidied the moment the card
|
|
/// is restored into a lane, which is the only state in which they matter.
|
|
///
|
|
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
|
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
|
public var defects: [IntegrityRules.Defect] = []
|
|
|
|
/// What each `.trash/` entry **is**, by the trash's own discriminator
|
|
/// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
|
|
/// `kind` value trusted outright, falling through to shape only when it does not answer.
|
|
///
|
|
/// A *reading*, not a rendering: `BoardModel.trash` parses every entry through the one card
|
|
/// parse (a trashed card is "an ordinary card in a special place"), and the container is flat,
|
|
/// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash
|
|
/// surface consumes it. Keyed by identity, so it survives the display sort.
|
|
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
|
|
|
/// The cards this walk found holding loose files — a **view over `defects`**, under the name it
|
|
/// has always had (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
|
|
///
|
|
/// In the order the walk met them, which is the order the relocation writes them in.
|
|
public var looseCardFiles: [LooseCardFiles] {
|
|
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
|
}
|
|
|
|
/// The legacy `deleted:` keys this walk found — the retired tombstone model's migration input,
|
|
/// as a **view over `defects`** (01-storage-format.md § Deletion).
|
|
///
|
|
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
|
public var legacyTombstones: [LegacyTombstone] {
|
|
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
|
}
|
|
|
|
/// The claimed board-root name found held by the wrong kind of node, if any — a **view over
|
|
/// `defects`** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
|
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
|
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
|
|
}
|
|
}
|
|
|
|
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
|
|
/// for what does.
|
|
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
|
/// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not
|
|
/// fail-fast (an interrupted two-step create must not brick the board). `path` is relative
|
|
/// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a
|
|
/// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never
|
|
/// this case.
|
|
case missingIndex(path: String)
|
|
|
|
/// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex,
|
|
/// `8-4-4-4-12`, any case, any version) —
|
|
/// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one,
|
|
/// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level
|
|
/// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the
|
|
/// board root.
|
|
case nonUUIDFolderIgnored(path: String)
|
|
|
|
/// A board-level `deleted:` key is legal per the frontmatter table but meaningless
|
|
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
|
|
case boardLevelDeletedIgnored
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case let .missingIndex(path):
|
|
"\(path): folder has no index.md, skipped"
|
|
case let .nonUUIDFolderIgnored(path):
|
|
"\(path): folder name is not UUID-shaped, ignored as a stray"
|
|
case .boardLevelDeletedIgnored:
|
|
"index.md: board-level 'deleted' key is meaningless, ignored"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error
|
|
|
|
/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to
|
|
/// the board root where one exists) plus `reason` says exactly what's wrong. No partial
|
|
/// loads: throwing this means `BoardLoader.load` produced nothing at all.
|
|
public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
|
|
public let path: String
|
|
public let reason: Reason
|
|
|
|
public var description: String { "\(path): \(reason.description)" }
|
|
|
|
public enum Reason: Sendable, Equatable, CustomStringConvertible {
|
|
/// The board root itself has no `index.md` — unlike every level below it, this is not
|
|
/// skip-and-warn: there is no board without one.
|
|
case boardRootMissingIndex
|
|
/// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a
|
|
/// 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?)
|
|
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.
|
|
case missingOrder
|
|
case malformedOrder(raw: String)
|
|
/// The board root exists but is a file, not a directory.
|
|
case notADirectory
|
|
/// The board root doesn't exist, or its contents couldn't be listed.
|
|
case unreadableRoot(message: String)
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .boardRootMissingIndex:
|
|
"board root is missing index.md"
|
|
case let .unparseableYAML(message, line):
|
|
if let line {
|
|
"unparseable YAML at line \(line): \(message)"
|
|
} else {
|
|
"unparseable YAML: \(message)"
|
|
}
|
|
case .missingSchema:
|
|
"missing required 'schema' field"
|
|
case let .malformedSchema(raw):
|
|
"malformed 'schema' field: \(raw)"
|
|
case let .schemaNewerThanApp(found):
|
|
"schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))"
|
|
case .missingOrder:
|
|
"missing required 'order' field"
|
|
case let .malformedOrder(raw):
|
|
"malformed 'order' field: \(raw)"
|
|
case .notADirectory:
|
|
"board root is not a directory"
|
|
case let .unreadableRoot(message):
|
|
"board root is unreadable: \(message)"
|
|
}
|
|
}
|
|
}
|
|
}
|