diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index ef7ccc9..cbaa272 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -81,7 +81,7 @@ Body: lane description / WIP policy / notes. Body: the card's content — the whole point. -Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection. +Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection. ## Enhanced schema (reserved, out of scope) @@ -108,7 +108,7 @@ Carried over unchanged — gapped fractional ranks: ## Deletion — tombstones -- Deleting writes `deleted: ` into frontmatter; tombstoned items (and everything beneath) are hidden from the board and surface in the **trash quasi-lane** (03-board-ui.md) — a pure view. **Tombstoned items stay exactly where they are on disk**, which is what makes restore (Put Back = removing the key) position-preserving: the item reappears at its recorded `order` among its current siblings. One acknowledged edge: a renumber rescales *visible* siblings only (tombstones are inert to ordering), so a post-renumber Put Back can land among different neighbors than the ones it was deleted between — same lane, deterministic placement, just not always the original gap. +- Deleting writes `deleted: ` into frontmatter; tombstoned items (and everything beneath) are hidden from the board. **Hiding beneath is the renderer's walk, not a stored flag** (pinned by the fixture suite): the snapshot flags only the item carrying `deleted:` — a live card inside a tombstoned lane stays `isDeleted == false`, and every consumer of the model (board view, trash, search, counts) must treat ancestor tombstones as hiding the subtree rather than filtering on the item flag alone and surface in the **trash quasi-lane** (03-board-ui.md) — a pure view. **Tombstoned items stay exactly where they are on disk**, which is what makes restore (Put Back = removing the key) position-preserving: the item reappears at its recorded `order` among its current siblings. One acknowledged edge: a renumber rescales *visible* siblings only (tombstones are inert to ordering), so a post-renumber Put Back can land among different neighbors than the ones it was deleted between — same lane, deterministic placement, just not always the original gap. - Tombstones are **inert to ordering**: appends and renumbering operate on visible siblings only; a restored item re-enters at its recorded `order`, ties broken deterministically. - A folder that disappears without a tombstone (Finder deletion) is also a delete; the app reflects it (such items are gone entirely — they never enter the trash). - A `deleted:` key at **board level** is legal per the common frontmatter table but meaningless — a board can't tombstone itself out of its own window. The loader ignores it and logs (the lenient-values philosophy: preserved verbatim, invisible in the UI). diff --git a/Fixtures/Malformed/board-root-missing-index.kanban/.keep-this-dir-in-git b/Fixtures/Malformed/board-root-missing-index.kanban/.keep-this-dir-in-git new file mode 100644 index 0000000..409620c --- /dev/null +++ b/Fixtures/Malformed/board-root-missing-index.kanban/.keep-this-dir-in-git @@ -0,0 +1 @@ +there is no board without an index.md diff --git a/Fixtures/Malformed/explicit-null-order.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/explicit-null-order.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..841b9ff --- /dev/null +++ b/Fixtures/Malformed/explicit-null-order.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: +title: Lane With Null Order +--- +The hand-editor started the 'order' key and never gave it a value — reads +as missing, not malformed. diff --git a/Fixtures/Malformed/explicit-null-order.kanban/index.md b/Fixtures/Malformed/explicit-null-order.kanban/index.md new file mode 100644 index 0000000..ed97047 --- /dev/null +++ b/Fixtures/Malformed/explicit-null-order.kanban/index.md @@ -0,0 +1,4 @@ +--- +schema: 1 +title: Board Is Fine +--- diff --git a/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..2b620e9 --- /dev/null +++ b/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +title: Card Missing Order +--- +No 'order' key — required on every card. diff --git a/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..68adfef --- /dev/null +++ b/Fixtures/Malformed/missing-order-card.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Lane Is Fine +--- diff --git a/Fixtures/Malformed/missing-order-card.kanban/index.md b/Fixtures/Malformed/missing-order-card.kanban/index.md new file mode 100644 index 0000000..ed97047 --- /dev/null +++ b/Fixtures/Malformed/missing-order-card.kanban/index.md @@ -0,0 +1,4 @@ +--- +schema: 1 +title: Board Is Fine +--- diff --git a/Fixtures/Malformed/missing-order-lane.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/missing-order-lane.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..5823b1a --- /dev/null +++ b/Fixtures/Malformed/missing-order-lane.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +title: Lane Missing Order +--- +No 'order' key — required on every lane. diff --git a/Fixtures/Malformed/missing-order-lane.kanban/index.md b/Fixtures/Malformed/missing-order-lane.kanban/index.md new file mode 100644 index 0000000..ed97047 --- /dev/null +++ b/Fixtures/Malformed/missing-order-lane.kanban/index.md @@ -0,0 +1,4 @@ +--- +schema: 1 +title: Board Is Fine +--- diff --git a/Fixtures/Malformed/missing-schema.kanban/index.md b/Fixtures/Malformed/missing-schema.kanban/index.md new file mode 100644 index 0000000..1169bd9 --- /dev/null +++ b/Fixtures/Malformed/missing-schema.kanban/index.md @@ -0,0 +1,5 @@ +--- +title: A Board Missing Its Schema +--- +No 'schema' field here — the loader fails fast before looking at anything +else. diff --git a/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..12f5472 --- /dev/null +++ b/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: banana +title: Card With Non-Numeric Order +--- +'order' is present but not a number — malformed, not missing; the same +loud rejection as a missing value. diff --git a/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..68adfef --- /dev/null +++ b/Fixtures/Malformed/non-numeric-order.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Lane Is Fine +--- diff --git a/Fixtures/Malformed/non-numeric-order.kanban/index.md b/Fixtures/Malformed/non-numeric-order.kanban/index.md new file mode 100644 index 0000000..ed97047 --- /dev/null +++ b/Fixtures/Malformed/non-numeric-order.kanban/index.md @@ -0,0 +1,4 @@ +--- +schema: 1 +title: Board Is Fine +--- diff --git a/Fixtures/Malformed/schema-newer-than-app.kanban/index.md b/Fixtures/Malformed/schema-newer-than-app.kanban/index.md new file mode 100644 index 0000000..6e67507 --- /dev/null +++ b/Fixtures/Malformed/schema-newer-than-app.kanban/index.md @@ -0,0 +1,5 @@ +--- +schema: 2 +title: From The Future +--- +This board was written by a newer version of the app than this one. diff --git a/Fixtures/Malformed/unparseable-yaml.kanban/index.md b/Fixtures/Malformed/unparseable-yaml.kanban/index.md new file mode 100644 index 0000000..962f93f --- /dev/null +++ b/Fixtures/Malformed/unparseable-yaml.kanban/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +bad: [1, 2 +--- +Body text. diff --git a/Fixtures/README.md b/Fixtures/README.md index ccb10e7..5e6f019 100644 --- a/Fixtures/README.md +++ b/Fixtures/README.md @@ -2,4 +2,36 @@ Golden fixture boards for the storage-contract test suite — real on-disk folder trees, not inline strings, so the same fixtures can later drive XCUITests via the `--open-board` launch hook. -Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. +Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`. + +Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. + +## Valid/ — one board per tolerated/valid case + +| Board | Case | +|---|---| +| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Also the board every `index.md` in the tree is round-tripped against. | +| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). | +| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. | +| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. | +| `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. | +| `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. | +| `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. | +| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. | +| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. | +| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. | + +## Malformed/ — one board per fail-fast case + +Each board is minimal: one broken thing. + +| Board | Case | +|---|---| +| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. | +| `missing-schema.kanban` | Board root `index.md` has no `schema` key. | +| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. | +| `missing-order-lane.kanban` | A UUID-shaped lane `index.md` with no `order` key. | +| `missing-order-card.kanban` | A UUID-shaped card `index.md` with no `order` key. | +| `explicit-null-order.kanban` | A lane's `order:` key present with no value — reads as missing, not malformed. | +| `non-numeric-order.kanban` | A card's `order: banana` — present but not a number. | +| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. | diff --git a/Fixtures/Valid/board-level-deleted.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/board-level-deleted.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..e17daf0 --- /dev/null +++ b/Fixtures/Valid/board-level-deleted.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Also Business As Usual +--- diff --git a/Fixtures/Valid/board-level-deleted.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/board-level-deleted.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..b9b7ae5 --- /dev/null +++ b/Fixtures/Valid/board-level-deleted.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Business As Usual +--- diff --git a/Fixtures/Valid/board-level-deleted.kanban/index.md b/Fixtures/Valid/board-level-deleted.kanban/index.md new file mode 100644 index 0000000..189f194 --- /dev/null +++ b/Fixtures/Valid/board-level-deleted.kanban/index.md @@ -0,0 +1,9 @@ +--- +schema: 1 +title: Board With A Stray 'deleted' Key +deleted: 2026-01-01T00:00:00Z +--- +'deleted' is legal per the common frontmatter table but meaningless at +board level — a board can't tombstone itself out of its own window. The +loader ignores it (and logs a warning) rather than acting on it; the rest +of the board loads completely normally. diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..c3cbb13 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 1024 +title: 2048 +--- +An unquoted integer title coerces to its source text, "2048". diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md new file mode 100644 index 0000000..19e0989 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: 2048 +title: [a, b] +--- +A sequence has no sensible string reading — malformed, falls back to the +untitled placeholder. diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/50000000-0000-4000-8000-000000000005/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/50000000-0000-4000-8000-000000000005/index.md new file mode 100644 index 0000000..8a2be95 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/50000000-0000-4000-8000-000000000005/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: 3072 +iconColor: 42 +--- +An integer iconColor coerces to its source text, "42" — not a real color, +but the value round-trips rather than being rejected. diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/60000000-0000-4000-8000-000000000006/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/60000000-0000-4000-8000-000000000006/index.md new file mode 100644 index 0000000..fca50c0 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/60000000-0000-4000-8000-000000000006/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: 4096 +background: {x: 1} +--- +A mapping has no sensible string reading — malformed, falls back to no +color. diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/70000000-0000-4000-8000-000000000007/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/70000000-0000-4000-8000-000000000007/index.md new file mode 100644 index 0000000..7422481 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/70000000-0000-4000-8000-000000000007/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 5120 +background: 12345 +--- diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/80000000-0000-4000-8000-000000000008/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/80000000-0000-4000-8000-000000000008/index.md new file mode 100644 index 0000000..739f6e6 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/80000000-0000-4000-8000-000000000008/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +order: 6144 +title: Deleted With An Unusable Timestamp +deleted: definitely-not-a-date +--- +The user's intent to delete outranks the broken date — this card still +tombstones, its deletion date just unknown. diff --git a/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..eac9804 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 1024 +title: Width Coerces From A Quoted Number +width: "3" +--- diff --git a/Fixtures/Valid/coercion.kanban/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/coercion.kanban/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..4aeef71 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +order: 2048 +title: Width Is Unusable — Not An Integer +width: 1.5 +--- +A malformed width is preserved on disk and simply not used — never +rejected, never rewritten. diff --git a/Fixtures/Valid/coercion.kanban/index.md b/Fixtures/Valid/coercion.kanban/index.md new file mode 100644 index 0000000..0c8d089 --- /dev/null +++ b/Fixtures/Valid/coercion.kanban/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +title: Coercion And Lenient-Field Fallback +--- +Schema-owned display fields coerce where a sensible reading exists (a +wrong-type scalar reads as its source text) and fall back to their default +where none does (a sequence/mapping, a non-integer width). A present but +unusable 'deleted' timestamp still tombstones — presence outranks validity. diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..4544b22 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Should Render First (folder starts with 1) +--- diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..5c52ee0 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Should Render Second (folder starts with 2) +--- diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..7eb7f79 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Should Render Third (folder starts with 3) +--- diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..24edba4 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Lane With Three Tied Cards +--- diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/index.md new file mode 100644 index 0000000..e76c192 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 2048 +title: Should Render Before Lane bbb... (same order, 'a' < 'b') +--- diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/index.md new file mode 100644 index 0000000..02a9e54 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 2048 +title: Should Render After Lane aaa... (same order, 'b' > 'a') +--- diff --git a/Fixtures/Valid/duplicate-order-tie-break.kanban/index.md b/Fixtures/Valid/duplicate-order-tie-break.kanban/index.md new file mode 100644 index 0000000..7dda6f6 --- /dev/null +++ b/Fixtures/Valid/duplicate-order-tie-break.kanban/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: Duplicate Order Values, Tie-Broken By Folder Name +--- +Two siblings that share an 'order' render in a deterministic order — +ascending 'order' first, folder name (lexicographic) as the tiebreak. +This board exercises the tiebreak at both lane level and card level. diff --git a/Fixtures/Valid/duplicate-top-level-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/duplicate-top-level-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..02f61bd --- /dev/null +++ b/Fixtures/Valid/duplicate-top-level-keys.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 2048 +title: First Title +title: Second Title +--- diff --git a/Fixtures/Valid/duplicate-top-level-keys.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/duplicate-top-level-keys.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..e28e20b --- /dev/null +++ b/Fixtures/Valid/duplicate-top-level-keys.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,9 @@ +--- +schema: 1 +order: 1024 +order: 4096 +title: Dup Order Lane +--- +Duplicate keys aren't special-cased by field — 'order' is a strict, +schema-owned field and still resolves via last-wins at the document layer, +before the loader ever validates it. This is NOT a fail-fast case. diff --git a/Fixtures/Valid/duplicate-top-level-keys.kanban/index.md b/Fixtures/Valid/duplicate-top-level-keys.kanban/index.md new file mode 100644 index 0000000..e649e8c --- /dev/null +++ b/Fixtures/Valid/duplicate-top-level-keys.kanban/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: Draft Name +title: Final Name +--- +A hand-edit that types 'title' twice — strict YAML would reject the file +outright. The engine reads the last occurrence and keeps both on disk. diff --git a/Fixtures/Valid/interrupted-create.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/interrupted-create.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..b408c98 --- /dev/null +++ b/Fixtures/Valid/interrupted-create.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 1024 +title: A Real Card +--- +Body. diff --git a/Fixtures/Valid/interrupted-create.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/interrupted-create.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..7a08d73 --- /dev/null +++ b/Fixtures/Valid/interrupted-create.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: A Real Lane +--- diff --git a/Fixtures/Valid/interrupted-create.kanban/index.md b/Fixtures/Valid/interrupted-create.kanban/index.md new file mode 100644 index 0000000..8d4da51 --- /dev/null +++ b/Fixtures/Valid/interrupted-create.kanban/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +title: Interrupted Two-Step Create +--- +The app creates a folder first and writes index.md a moment later — a crash +or a killed app between those two steps must not brick the board. diff --git a/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/50000000-0000-4000-8000-000000000005/index.md b/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/50000000-0000-4000-8000-000000000005/index.md new file mode 100644 index 0000000..7ca1fc7 --- /dev/null +++ b/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/50000000-0000-4000-8000-000000000005/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 1024 +title: Real Card +--- +Body. diff --git a/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/draft/index.md b/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/draft/index.md new file mode 100644 index 0000000..d99eaef --- /dev/null +++ b/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/draft/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 9999 +title: This looks like a card but isn't named like one +--- diff --git a/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..bfd014f --- /dev/null +++ b/Fixtures/Valid/non-uuid-strays.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Real Lane +--- diff --git a/Fixtures/Valid/non-uuid-strays.kanban/index.md b/Fixtures/Valid/non-uuid-strays.kanban/index.md new file mode 100644 index 0000000..61dafc7 --- /dev/null +++ b/Fixtures/Valid/non-uuid-strays.kanban/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: Non-UUID Strays At Every Depth +--- +Name shape gates level detection — a folder that isn't UUIDv4-shaped is a +stray, whether or not it holds an index.md, and whether it sits at lane +depth or card depth. diff --git a/Fixtures/Valid/non-uuid-strays.kanban/todo-notes/index.md b/Fixtures/Valid/non-uuid-strays.kanban/todo-notes/index.md new file mode 100644 index 0000000..17a97c4 --- /dev/null +++ b/Fixtures/Valid/non-uuid-strays.kanban/todo-notes/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 9999 +title: This looks like a lane but isn't named like one +--- +Never rendered — 'todo-notes' isn't UUIDv4-shaped. diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sketch.png b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sketch.png new file mode 100644 index 0000000..62bea63 --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sketch.png @@ -0,0 +1 @@ +not-a-real-png-just-a-placeholder-attachment diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/comments/60000000-0000-4000-8000-000000000006/index.md b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/comments/60000000-0000-4000-8000-000000000006/index.md new file mode 100644 index 0000000..a335416 --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/comments/60000000-0000-4000-8000-000000000006/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +author: rzen +created: 2026-07-21T08:30:00Z +--- +Comments are enhanced-schema and out of scope for this version — this +folder is here only to prove it's preserved verbatim and never descended +into (cards are leaves; the loader never scans past index.md). diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..f4125dd --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,17 @@ +--- +schema: 1 +title: "Design the fixture taxonomy" +order: 1024 +created: 2026-07-20T10:15:00Z +modified: 2026-07-26T20:07:30Z +modified-by: claude +source: DESIGN/01-storage-format.md # inline comment on a plain scalar +project: lanework +sphere: work +labels: [m1-storage-read] +--- +Enumerate every fail-fast and tolerated case from the storage format and +build one golden fixture board per case. + +- attachments/ holds a sketch +- comments/ holds a placeholder future-comment folder diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md new file mode 100644 index 0000000..63a00cf --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md @@ -0,0 +1,9 @@ +--- +schema: 1 +title: Wire up the loader's stray tolerance +order: 2048 +background: coral +icon: flag.fill +iconColor: orange +--- +Plain, unquoted title this time — mixing styles on purpose. diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..681b1ff --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,10 @@ +--- +schema: 1 +order: 1024 +title: Doing +width: 2 +background: '#3478F6' +icon: hammer.fill +iconColor: blue +--- +WIP limit: 3 cards. Pull from Backlog only when there's room. diff --git a/Fixtures/Valid/rich-board.kanban/20000000-0000-4000-8000-000000000002/50000000-0000-4000-8000-000000000005/index.md b/Fixtures/Valid/rich-board.kanban/20000000-0000-4000-8000-000000000002/50000000-0000-4000-8000-000000000005/index.md new file mode 100644 index 0000000..2eccb85 --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/20000000-0000-4000-8000-000000000002/50000000-0000-4000-8000-000000000005/index.md @@ -0,0 +1,8 @@ +--- +schema: 1 +order: 1024 +title: 'Ship v1' +created: 2026-06-01T12:00:00Z +modified: 2026-07-15T09:00:00Z +--- +Shipped. 🎉 diff --git a/Fixtures/Valid/rich-board.kanban/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/rich-board.kanban/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..173ce81 --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: 2048 +title: Done +background: green +--- +Completed work lives here until someone clears it out. diff --git a/Fixtures/Valid/rich-board.kanban/index.md b/Fixtures/Valid/rich-board.kanban/index.md new file mode 100644 index 0000000..646ab55 --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/index.md @@ -0,0 +1,22 @@ +--- +schema: 1 +# board settings, hand-written +title: "Rich Demo Board" +created: 2026-07-01T09:00:00Z +modified: 2026-07-26T16:41:38Z +modified-by: claude +background: "#1E1E1E" +icon: rectangle.stack.fill +iconColor: purple + +project: lanework # agent overlay +sphere: work +labels: [feature, docs] +template: {order: 3} +# trailing note before close +--- +A demo board exercising the full breadth of the storage contract: several +lanes, several cards, body text, styling, and unknown/reserved frontmatter +keys living alongside the schema-owned ones. + +Nothing here is special-cased — it's an ordinary board. diff --git a/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..e3c72be --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,6 @@ +--- +schema: 1 +order: 1024 +title: A Card With Junk Inside It +--- +Body. diff --git a/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/notes/whatever.txt b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/notes/whatever.txt new file mode 100644 index 0000000..2d495f5 --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/notes/whatever.txt @@ -0,0 +1 @@ +this whole subfolder is never scanned — cards are leaves diff --git a/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/scratch.md b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/scratch.md new file mode 100644 index 0000000..fb188b9 --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/scratch.md @@ -0,0 +1 @@ +scratch diff --git a/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/draft.txt b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/draft.txt new file mode 100644 index 0000000..9dd0f8d --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/draft.txt @@ -0,0 +1 @@ +draft notes diff --git a/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..3fc14fb --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: A Lane With Junk Beside It +--- diff --git a/Fixtures/Valid/stray-files.kanban/README.txt b/Fixtures/Valid/stray-files.kanban/README.txt new file mode 100644 index 0000000..0eb9ef6 --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/README.txt @@ -0,0 +1 @@ +board readme diff --git a/Fixtures/Valid/stray-files.kanban/index.md b/Fixtures/Valid/stray-files.kanban/index.md new file mode 100644 index 0000000..6affa72 --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: Stray Files Everywhere +--- +Stray files at board, lane, and card level are ignored — preserved +verbatim, never rendered, never warned about (only directories are level +candidates at all). diff --git a/Fixtures/Valid/stray-files.kanban/notes.log b/Fixtures/Valid/stray-files.kanban/notes.log new file mode 100644 index 0000000..ede4f33 --- /dev/null +++ b/Fixtures/Valid/stray-files.kanban/notes.log @@ -0,0 +1 @@ +log line diff --git a/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md b/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md new file mode 100644 index 0000000..e5cba72 --- /dev/null +++ b/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: A Living Card +--- diff --git a/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md b/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md new file mode 100644 index 0000000..a12d683 --- /dev/null +++ b/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: 2048 +title: A Tombstoned Card +deleted: 2026-07-10T08:00:00Z +--- +Still on disk, at its recorded order, just hidden from the board. diff --git a/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..0fe603c --- /dev/null +++ b/Fixtures/Valid/tombstones.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,5 @@ +--- +schema: 1 +order: 1024 +title: Still Here +--- diff --git a/Fixtures/Valid/tombstones.kanban/20000000-0000-4000-8000-000000000002/50000000-0000-4000-8000-000000000005/index.md b/Fixtures/Valid/tombstones.kanban/20000000-0000-4000-8000-000000000002/50000000-0000-4000-8000-000000000005/index.md new file mode 100644 index 0000000..37babd6 --- /dev/null +++ b/Fixtures/Valid/tombstones.kanban/20000000-0000-4000-8000-000000000002/50000000-0000-4000-8000-000000000005/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +order: 1024 +title: Not Itself Deleted +--- +My lane is tombstoned but I am not — isDeleted keys only on my own +'deleted' field, never inherited from an ancestor. diff --git a/Fixtures/Valid/tombstones.kanban/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/tombstones.kanban/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..4732387 --- /dev/null +++ b/Fixtures/Valid/tombstones.kanban/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,10 @@ +--- +schema: 1 +order: 2048 +title: A Tombstoned Lane +deleted: 2026-07-11T09:30:00Z +--- +Everything beneath a tombstoned lane is hidden from the board too — but +that's a rendering rule, not a loader rule: the loader still reports this +lane's card structurally, un-flagged, since the card's own 'deleted' key +is absent. diff --git a/Fixtures/Valid/tombstones.kanban/index.md b/Fixtures/Valid/tombstones.kanban/index.md new file mode 100644 index 0000000..b418315 --- /dev/null +++ b/Fixtures/Valid/tombstones.kanban/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: Tombstoned Lane And Card Still On Disk +--- +Deletion writes 'deleted:' into frontmatter; the item never leaves disk. +The loader keeps tombstoned items in the snapshot, flagged — hiding them +from the board is a rendering concern, not a loading one. diff --git a/Fixtures/Valid/unknown-key-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md b/Fixtures/Valid/unknown-key-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md new file mode 100644 index 0000000..74b3bf3 --- /dev/null +++ b/Fixtures/Valid/unknown-key-order.kanban/10000000-0000-4000-8000-000000000001/20000000-0000-4000-8000-000000000002/index.md @@ -0,0 +1,12 @@ +--- +schema: 1 +labels: [bug, urgent] +order: 1024 +assignees: [bob, carol] +title: Card With Unknowns +due: 2026-08-15 +remote: {system: github, id: 42} +agent-scratch: notes here +--- +Card body describing the bug. Unknown/reserved keys are interleaved with +schema-owned ones on purpose, to prove ordering survives the interleave. diff --git a/Fixtures/Valid/unknown-key-order.kanban/10000000-0000-4000-8000-000000000001/index.md b/Fixtures/Valid/unknown-key-order.kanban/10000000-0000-4000-8000-000000000001/index.md new file mode 100644 index 0000000..5c17c7d --- /dev/null +++ b/Fixtures/Valid/unknown-key-order.kanban/10000000-0000-4000-8000-000000000001/index.md @@ -0,0 +1,9 @@ +--- +schema: 1 +order: 1024 +remote-state: syncing +title: Lane With Unknowns +assignees: [alice] +due: 2026-08-01 +custom: xyz +--- diff --git a/Fixtures/Valid/unknown-key-order.kanban/index.md b/Fixtures/Valid/unknown-key-order.kanban/index.md new file mode 100644 index 0000000..3d298c3 --- /dev/null +++ b/Fixtures/Valid/unknown-key-order.kanban/index.md @@ -0,0 +1,12 @@ +--- +schema: 1 +project: lanework +title: Unknown Key Ordering +sphere: work +template: {order: 5} +labels: [x, y, z] +custom-note: keep me +--- +Unknown keys and their relative order are preserved verbatim, on every +level — this is load-bearing for agent overlays (project:, sphere:, +custom tags). diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift new file mode 100644 index 0000000..c6c1a22 --- /dev/null +++ b/KanbanTests/FixtureBoardTests.swift @@ -0,0 +1,431 @@ +import Foundation +import Testing +@testable import Kanban + +// Golden fixture-board suite (01-storage-format.md, 02-architecture.md § Testing): real +// on-disk folder trees under `Fixtures/`, one board per tolerated/valid case and one per +// fail-fast case, asserted against `BoardLoader`. Complements `BoardLoaderTests.swift`'s +// synthetic smoke coverage — this suite is the comprehensive, hand-authored, disk-backed +// counterpart. + +// MARK: - Bundle resource resolution + +/// A tiny anchor class purely so `Bundle(for:)` can find the test bundle — there is no +/// `Bundle.module` in an xcodeproj target (that's an SPM-only convenience). +private final class FixtureBundleAnchor {} + +/// The `Fixtures/` folder reference, copied into the test bundle's resources verbatim +/// (`project.yml`'s `KanbanTests` target). Real directories on disk, not synthesized strings. +private func fixturesRoot() -> URL { + guard let resources = Bundle(for: FixtureBundleAnchor.self).resourceURL else { + fatalError("test bundle has no resourceURL") + } + return resources.appendingPathComponent("Fixtures", isDirectory: true) +} + +private func fixtureBoard(_ relativePath: String) -> URL { + fixturesRoot().appendingPathComponent(relativePath, isDirectory: true) +} + +private func loadFixture(_ relativePath: String) throws -> LoadResult { + try BoardLoader.load(boardRoot: fixtureBoard(relativePath)) +} + +/// Every `index.md` beneath `root`, found by walking the real tree — used by the round-trip +/// assertions, which don't want to hardcode which files exist. +private func allIndexMdFiles(under root: URL) throws -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + var results: [URL] = [] + for case let url as URL in enumerator where url.lastPathComponent == "index.md" { + results.append(url) + } + return results +} + +private func iso8601(_ text: String) -> Date { + guard let date = ISO8601DateFormatter().date(from: text) else { + fatalError("bad test fixture: '\(text)' is not ISO-8601") + } + return date +} + +private func expectFixtureFailure( + _ relativePath: String, + path: String, + reasonDescription: String, + _ matches: (BoardLoadError.Reason) -> Bool +) { + do { + _ = try loadFixture(relativePath) + Issue.record("expected \(relativePath) to fail with \(reasonDescription) at '\(path)', but it loaded") + } catch let error as BoardLoadError { + #expect(error.path == path, "\(relativePath): wrong path in error") + #expect(matches(error.reason), "\(relativePath): expected \(reasonDescription), got \(error.reason)") + } catch { + Issue.record("\(relativePath): expected a BoardLoadError, got \(error)") + } +} + +// MARK: - Valid/rich-board.kanban + +private enum RichBoard { + static let laneDoing = "10000000-0000-4000-8000-000000000001" + static let laneDone = "20000000-0000-4000-8000-000000000002" + static let cardTaxonomy = "30000000-0000-4000-8000-000000000003" + static let cardSecond = "40000000-0000-4000-8000-000000000004" + static let cardShip = "50000000-0000-4000-8000-000000000005" +} + +struct FixtureRichBoardTests { + @Test func loadsFullShapeWithStylingAndNoWarnings() throws { + let result = try loadFixture("Valid/rich-board.kanban") + let model = result.model + + #expect(model.title.value == "Rich Demo Board") + #expect(model.background.value == "#1E1E1E") + #expect(model.icon.value == "rectangle.stack.fill") + #expect(model.iconColor.value == "purple") + #expect(model.modifiedBy.value == "claude") + if case let .mapping(pairs) = model.template { + #expect(pairs.count == 1) + } else { + Issue.record("expected board template to be a mapping, got \(String(describing: model.template))") + } + + #expect(model.lanes.map(\.id.rawValue) == [RichBoard.laneDoing, RichBoard.laneDone]) + #expect(result.warnings.isEmpty) + + let doing = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDoing }) + #expect(doing.title.value == "Doing") + #expect(doing.width.value == 2) + #expect(doing.background.value == "#3478F6") + #expect(doing.cards.map(\.id.rawValue) == [RichBoard.cardTaxonomy, RichBoard.cardSecond]) + + let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy }) + #expect(taxonomy.title.value == "Design the fixture taxonomy") + #expect(taxonomy.modifiedBy.value == "claude") + #expect(taxonomy.body.contains("attachments/ holds a sketch")) + + let done = try #require(model.lanes.first { $0.id.rawValue == RichBoard.laneDone }) + #expect(done.cards.map(\.id.rawValue) == [RichBoard.cardShip]) + #expect(done.cards[0].title.value == "Ship v1") + } + + @Test func boardUnknownAndReservedKeysPreserveOrder() throws { + let result = try loadFixture("Valid/rich-board.kanban") + // schema-owned keys (schema, title, created, modified, modified-by, background, icon, + // iconColor) are filtered out; only the agent-overlay and reserved keys remain, in the + // order they were written. + #expect(result.model.document.unknownFields.map(\.key) == ["project", "sphere", "labels", "template"]) + } + + /// The whole-tree round-trip guarantee (01-storage-format.md § Fractal layout: "the app + /// never reformats a body it didn't change"): every `index.md` under the rich board parses + /// and re-serializes to its original bytes, untouched. + @Test func everyIndexMdInTheTreeRoundTripsByteIdentically() throws { + let root = fixtureBoard("Valid/rich-board.kanban") + let files = try allIndexMdFiles(under: root) + #expect(files.count == 7) // board + 2 lanes + 3 cards + the one comment folder's index.md + + for file in files { + let text = try String(contentsOf: file, encoding: .utf8) + let document = try FrontmatterDocument.parse(text) + #expect(document.serialized() == text, "\(file.path) did not round-trip byte-identically") + } + } +} + +// MARK: - Valid/interrupted-create.kanban + +struct FixtureInterruptedCreateTests { + @Test func indexlessFoldersAreSkippedNotFailed() throws { + let lane = "10000000-0000-4000-8000-000000000001" + let laneInterrupted = "20000000-0000-4000-8000-000000000002" + let card = "30000000-0000-4000-8000-000000000003" + let cardInterrupted = "40000000-0000-4000-8000-000000000004" + + let result = try loadFixture("Valid/interrupted-create.kanban") + #expect(result.model.lanes.map(\.id.rawValue) == [lane]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) + + #expect(result.warnings.count == 2) + #expect(result.warnings.contains(.missingIndex(path: laneInterrupted))) + #expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(cardInterrupted)"))) + } +} + +// MARK: - Valid/non-uuid-strays.kanban + +struct FixtureNonUUIDStraysTests { + @Test func nonUUIDFoldersAreStraysAtEveryDepthRegardlessOfIndex() throws { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "50000000-0000-4000-8000-000000000005" + + let result = try loadFixture("Valid/non-uuid-strays.kanban") + #expect(result.model.lanes.map(\.id.rawValue) == [lane]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) + + #expect(result.warnings.count == 4) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo-notes"))) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "scratch"))) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/draft"))) + #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/wip"))) + } +} + +// MARK: - Valid/stray-files.kanban + +struct FixtureStrayFilesTests { + @Test func strayFilesEverywhereProduceNoWarningsAndDontAffectTheModel() throws { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "20000000-0000-4000-8000-000000000002" + + let result = try loadFixture("Valid/stray-files.kanban") + #expect(result.warnings.isEmpty) + #expect(result.model.lanes.map(\.id.rawValue) == [lane]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) + } +} + +// MARK: - Valid/tombstones.kanban + +struct FixtureTombstonesTests { + @Test func tombstonedLaneAndCardStayInTheSnapshotFlagged() throws { + let laneLive = "10000000-0000-4000-8000-000000000001" + let laneDead = "20000000-0000-4000-8000-000000000002" + let cardLive = "30000000-0000-4000-8000-000000000003" + let cardDead = "40000000-0000-4000-8000-000000000004" + let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005" + + let result = try loadFixture("Valid/tombstones.kanban") + #expect(result.warnings.isEmpty) + #expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead]) + + let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive }) + #expect(live.isDeleted == false) + #expect(live.cards.map(\.id.rawValue) == [cardLive, cardDead]) + #expect(live.cards.map(\.isDeleted) == [false, true]) + + let dead = try #require(result.model.lanes.first { $0.id.rawValue == laneDead }) + #expect(dead.isDeleted == true) + #expect(dead.cards.map(\.id.rawValue) == [cardUnderDeadLane]) + // A tombstoned lane doesn't propagate deletion onto its children's own flag — the + // loader is structural, not recursive; hiding an ancestor's tombstoned subtree is a + // rendering concern, not a load-time one. + #expect(dead.cards[0].isDeleted == false) + } +} + +// MARK: - Valid/duplicate-order-tie-break.kanban + +struct FixtureDuplicateOrderTieBreakTests { + @Test func tiedLanesAndTiedCardsBreakByFolderNameAscending() throws { + let laneA = "10000000-0000-4000-8000-000000000001" + let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + let laneBBB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + let cardX = "10000000-0000-4000-8000-000000000001" + let cardY = "20000000-0000-4000-8000-000000000002" + let cardZ = "30000000-0000-4000-8000-000000000003" + + let result = try loadFixture("Valid/duplicate-order-tie-break.kanban") + // laneA (order 1024) first; laneAAA and laneBBB tie at 2048, broken 'a' < 'b'. + #expect(result.model.lanes.map(\.id.rawValue) == [laneA, laneAAA, laneBBB]) + + let lane = try #require(result.model.lanes.first { $0.id.rawValue == laneA }) + #expect(lane.cards.map(\.id.rawValue) == [cardX, cardY, cardZ]) + #expect(Set(lane.cards.map(\.order)) == [1024]) + } +} + +// MARK: - Valid/unknown-key-order.kanban + +struct FixtureUnknownKeyOrderTests { + @Test func unknownKeysPreserveDocumentOrderAtEveryLevel() throws { + let result = try loadFixture("Valid/unknown-key-order.kanban") + let model = result.model + #expect(model.document.unknownFields.map(\.key) == ["project", "sphere", "template", "labels", "custom-note"]) + + let lane = try #require(model.lanes.first) + #expect(lane.document.unknownFields.map(\.key) == ["remote-state", "assignees", "due", "custom"]) + + let card = try #require(lane.cards.first) + #expect(card.document.unknownFields.map(\.key) == ["labels", "assignees", "due", "remote", "agent-scratch"]) + } + + @Test func everyIndexMdRoundTripsByteIdentically() throws { + let root = fixtureBoard("Valid/unknown-key-order.kanban") + for file in try allIndexMdFiles(under: root) { + let text = try String(contentsOf: file, encoding: .utf8) + #expect(try FrontmatterDocument.parse(text).serialized() == text) + } + } +} + +// MARK: - Valid/coercion.kanban + +struct FixtureCoercionTests { + @Test func wrongTypeScalarsCoerceOrFallBackToDefaultPerField() throws { + let lane1 = "10000000-0000-4000-8000-000000000001" + let lane2 = "20000000-0000-4000-8000-000000000002" + let cardTitleInt = "30000000-0000-4000-8000-000000000003" + let cardTitleSeq = "40000000-0000-4000-8000-000000000004" + let cardIconColorInt = "50000000-0000-4000-8000-000000000005" + let cardBackgroundMap = "60000000-0000-4000-8000-000000000006" + let cardBackgroundInt = "70000000-0000-4000-8000-000000000007" + let cardDeletedBad = "80000000-0000-4000-8000-000000000008" + + let result = try loadFixture("Valid/coercion.kanban") + let model = result.model + + let laneWidthCoerces = try #require(model.lanes.first { $0.id.rawValue == lane1 }) + #expect(laneWidthCoerces.width == .valid(3)) // width: "3" (quoted string) coerces + + let laneWidthMalformed = try #require(model.lanes.first { $0.id.rawValue == lane2 }) + #expect(laneWidthMalformed.width == .malformed(raw: "1.5")) // non-integer, no sensible width + + func card(_ id: String) throws -> Card { + try #require(laneWidthCoerces.cards.first { $0.id.rawValue == id }) + } + + #expect(try card(cardTitleInt).title == .valid("2048")) + #expect(try card(cardTitleSeq).title == .malformed(raw: "[a, b]")) + #expect(try card(cardIconColorInt).iconColor == .valid("42")) + #expect(try card(cardBackgroundMap).background == .malformed(raw: "{x: 1}")) + #expect(try card(cardBackgroundInt).background == .valid("12345")) + + let deletedBad = try card(cardDeletedBad) + #expect(deletedBad.deleted == .malformed(raw: "definitely-not-a-date")) + // Presence outranks validity: an unusable timestamp still tombstones. + #expect(deletedBad.isDeleted == true) + } +} + +// MARK: - Valid/duplicate-top-level-keys.kanban + +struct FixtureDuplicateTopLevelKeysTests { + @Test func lastOccurrenceWinsAtBoardLaneAndCardLevel() throws { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "20000000-0000-4000-8000-000000000002" + + let result = try loadFixture("Valid/duplicate-top-level-keys.kanban") + #expect(result.warnings.isEmpty) // NOT a fail-fast case, per the newer design-doc rule + + #expect(result.model.title == .valid("Final Name")) + + let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane }) + #expect(loadedLane.order == 4096) // duplicated 'order' — a strict field — still last-wins + + let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card }) + #expect(loadedCard.title == .valid("Second Title")) + } + + /// Earlier occurrences of a duplicated key are invisible to every read but still preserved + /// verbatim on disk (01-storage-format.md § Frontmatter) — proven by round-tripping every + /// file in this board, not just asserting the winning value. + @Test func earlierOccurrencesSurviveOnDiskViaRoundTrip() throws { + let root = fixtureBoard("Valid/duplicate-top-level-keys.kanban") + let files = try allIndexMdFiles(under: root) + #expect(files.count == 3) + for file in files { + let text = try String(contentsOf: file, encoding: .utf8) + #expect(try FrontmatterDocument.parse(text).serialized() == text) + } + } +} + +// MARK: - Valid/board-level-deleted.kanban + +struct FixtureBoardLevelDeletedTests { + @Test func boardLevelDeletedIsIgnoredButRestOfBoardLoadsNormally() throws { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "20000000-0000-4000-8000-000000000002" + + let result = try loadFixture("Valid/board-level-deleted.kanban") + #expect(result.warnings.contains(.boardLevelDeletedIgnored)) + #expect(result.model.deleted == .valid(iso8601("2026-01-01T00:00:00Z"))) + + // Meaningless at board level, but never blanks the board — the rest loads as usual. + #expect(result.model.lanes.map(\.id.rawValue) == [lane]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) + } +} + +// MARK: - Malformed/*.kanban — fail-fast cases + +struct FixtureMalformedTests { + @Test func unparseableYAML() { + expectFixtureFailure("Malformed/unparseable-yaml.kanban", path: "index.md", reasonDescription: "unparseableYAML") { + if case .unparseableYAML = $0 { true } else { false } + } + } + + @Test func missingSchema() { + expectFixtureFailure("Malformed/missing-schema.kanban", path: "index.md", reasonDescription: "missingSchema") { + $0 == .missingSchema + } + } + + @Test func schemaNewerThanApp() { + expectFixtureFailure( + "Malformed/schema-newer-than-app.kanban", path: "index.md", reasonDescription: "schemaNewerThanApp(2)" + ) { + $0 == .schemaNewerThanApp(found: 2) + } + } + + @Test func missingOrderOnLane() { + let lane = "10000000-0000-4000-8000-000000000001" + expectFixtureFailure( + "Malformed/missing-order-lane.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder" + ) { + $0 == .missingOrder + } + } + + @Test func missingOrderOnCard() { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "20000000-0000-4000-8000-000000000002" + expectFixtureFailure( + "Malformed/missing-order-card.kanban", path: "\(lane)/\(card)/index.md", reasonDescription: "missingOrder" + ) { + $0 == .missingOrder + } + } + + /// Explicit null reads as missing (01-storage-format.md § Malformed input): `order:` with + /// nothing after it fails the same way a missing key does, not as `.malformedOrder`. + @Test func explicitNullOrderReadsAsMissing() { + let lane = "10000000-0000-4000-8000-000000000001" + expectFixtureFailure( + "Malformed/explicit-null-order.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder" + ) { + $0 == .missingOrder + } + } + + @Test func presentButNonNumericOrder() { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "20000000-0000-4000-8000-000000000002" + expectFixtureFailure( + "Malformed/non-numeric-order.kanban", + path: "\(lane)/\(card)/index.md", + reasonDescription: "malformedOrder(banana)" + ) { + $0 == .malformedOrder(raw: "banana") + } + } + + @Test func boardRootMissingIndex() { + expectFixtureFailure( + "Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex" + ) { + $0 == .boardRootMissingIndex + } + } +} diff --git a/README.md b/README.md index cad4885..e37efa4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ The defining consequence: anything that can read and write files is a first-clas Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/). -*Nothing user-facing has landed yet — the project is at the scaffold stage.* +*No UI yet — the storage foundation is in place:* + +- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards. ## Development