From 8e7c565e07ffa4057ed0a5dd1b1629241f93f83e Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 15:47:33 -0400 Subject: [PATCH] Adopt read-side coercion for schema-owned fields Design-review resolutions: schema-owned display fields coerce where a sensible reading exists (wrong-type scalars read as source text, width accepts exact-integer strings/doubles) and default where none does; null reads as missing; duplicate keys last-one-wins and inline-comment re-splicing recorded in the design (engine change follows). Strict schema/order fail-fast unchanged. 85 tests green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- DESIGN/01-storage-format.md | 4 +- Kanban/Storage/FrontmatterFields.swift | 67 +++++++++++++++++++------- KanbanTests/BoardLoaderTests.swift | 21 ++++++++ KanbanTests/FrontmatterTests.swift | 37 +++++++++++++- 4 files changed, 107 insertions(+), 22 deletions(-) diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index ef88b47..971155c 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -80,7 +80,7 @@ Body: lane description / WIP policy / notes. Body: the card's content — the whole point. -Colors / icons are **lenient**: a malformed value is preserved verbatim and simply not displayed. **`width` is lenient too** (it styles layout; it isn't structure): anything but an integer ≥ 1 — zero, negative, fractional, non-numeric — is preserved verbatim and renders as the default 1. Fail-fast is 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. +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 key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. 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. ## Enhanced schema (reserved, out of scope) @@ -115,7 +115,7 @@ Carried over unchanged — gapped fractional ranks: ## Malformed input — fail fast -Loud, specific error (path + what's wrong) for: unparseable YAML, missing required fields (`schema`; `order` where required), `schema` newer than the app, board root without `index.md`. No partial loads. The only tolerated absence is a missing `index.md` below the root (skip + warn, per Rules above). +Loud, specific error (path + what's wrong) for: unparseable YAML, missing required fields (`schema`; `order` where required), `schema` newer than the app, board root without `index.md`. No partial loads. The only tolerated absence is a missing `index.md` below the root (skip + warn, per Rules above). An explicitly null value (`order:` with nothing after it, `order: null`) reads as **missing** (settled): on a required field that is the missing-required-field rejection — it describes what the hand-editor actually did, started the key and never gave it a value; on an optional field, null is simply absent and the default applies. Fail-fast is the **initial-load** contract. Once a board is open, a failed live reload does not blank the board: the window keeps the last good snapshot and surfaces the same loud specifics in a non-modal banner — see 02-architecture.md's live-reload resilience. diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index 9e89e34..fdc8f25 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -9,8 +9,9 @@ public struct FrontmatterField: Sendable, Equatable { } /// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`, -/// `order`) from being silently coerced and lenient fields (colors, icons, `width`) from -/// erroring — the loader decides which reaction each one gets. +/// `order`) from being silently coerced — they fail the load instead. Lenient fields (colors, +/// icons, `width`) coerce where a sensible reading exists (01-storage-format.md § Frontmatter) +/// and only fall back to `.malformed` — rendered as the field's default — when none does. public enum FieldValue: Sendable, Equatable { case missing case valid(Value) @@ -42,12 +43,12 @@ extension FrontmatterDocument { // MARK: - Strict (structure — the loader fails fast on `.malformed`) public var schema: FieldValue { - read(FrontmatterKeys.schema) { if case let .int(value) = $0 { value } else { nil } } + read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } } } public var order: FieldValue { - read(FrontmatterKeys.order) { - switch $0 { + read(FrontmatterKeys.order) { value, _ in + switch value { case let .int(value): Double(value) case let .double(value): value default: nil @@ -55,40 +56,70 @@ extension FrontmatterDocument { } } - // MARK: - Lenient (a malformed value is preserved and simply not used) + // MARK: - Lenient (coerce where a sensible reading exists, else malformed → UI default) + /// Any scalar coerces to the text the author typed — a quoted string's own text (quotes and + /// escapes already resolved by the parser), or an unquoted scalar's exact source span + /// (`title: 2048` reads as `"2048"`). Only a sequence or mapping — no scalar reading exists + /// — is malformed. public var title: FieldValue { read(FrontmatterKeys.title, Self.string) } public var background: FieldValue { read(FrontmatterKeys.background, Self.string) } public var icon: FieldValue { read(FrontmatterKeys.icon, Self.string) } public var iconColor: FieldValue { read(FrontmatterKeys.iconColor, Self.string) } - /// Width multiplier; anything but an integer ≥ 1 is malformed and renders as the default 1. + /// Width multiplier. An int stays; a string or double with an exact integer reading ≥ 1 + /// coerces (`"2"`, `2.0` → `2`). Everything else — zero, negative, fractional, non-numeric, + /// bool, a sequence/mapping — is malformed and renders as the default 1. public var width: FieldValue { - read(FrontmatterKeys.width) { if case let .int(value) = $0, value >= 1 { value } else { nil } } + read(FrontmatterKeys.width) { value, _ in + switch value { + case let .int(value): value >= 1 ? value : nil + case let .double(value): Self.exactIntWidth(value) + case let .string(text): Double(text).flatMap(Self.exactIntWidth) + default: nil + } + } } - public var created: FieldValue { read(FrontmatterKeys.created, Self.date) } - public var modified: FieldValue { read(FrontmatterKeys.modified, Self.date) } - public var deleted: FieldValue { read(FrontmatterKeys.deleted, Self.date) } + public var created: FieldValue { read(FrontmatterKeys.created) { value, _ in Self.date(value) } } + public var modified: FieldValue { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } } + public var deleted: FieldValue { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } } /// Schema-owned, not an unknown key: the app clears it on every app-mediated write. public var modifiedBy: FieldValue { read(FrontmatterKeys.modifiedBy, Self.string) } // MARK: - - private func read(_ key: String, _ transform: (YAMLValue) -> Value?) -> FieldValue { + private func read(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue { guard let value = value(for: key) else { return .missing } if case .null = value { return .missing } - if let typed = transform(value) { return .valid(typed) } - return .malformed(raw: rawValue(for: key) ?? value.description) + let raw = rawValue(for: key) ?? value.description + if let typed = transform(value, raw) { return .valid(typed) } + return .malformed(raw: raw) } - private static func string(_ value: YAMLValue) -> String? { - if case let .string(text) = value { return text } - return nil + /// A quoted string reads as its own (already-unquoted, already-unescaped) text; any other + /// scalar reads as its raw source span — what the author typed. Only a sequence/mapping has + /// no sensible string reading. + private static func string(_ value: YAMLValue, raw: String) -> String? { + switch value { + case let .string(text): text + case .int, .double, .bool, .date: raw + default: nil + } } - /// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar. + /// An integer-valued double or numeric string ≥ 1 coerces; anything else (fractional, + /// non-numeric, out of `Int` range) has no sensible width reading. + private static func exactIntWidth(_ value: Double) -> Int? { + guard value.truncatingRemainder(dividingBy: 1) == 0, value >= 1, value <= Double(Int.max) else { return nil } + return Int(value) + } + + /// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar, + /// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible + /// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a + /// malformed `deleted` still deletes — presence, not validity, is what counts. private static func date(_ value: YAMLValue) -> Date? { switch value { case let .date(date): date diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 23b076c..3e8337d 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -100,6 +100,27 @@ struct BoardLoaderWellFormedTests { #expect(result.warnings.isEmpty) } + /// Tombstone semantics key on the `deleted` key's *presence*, not its validity + /// (01-storage-format.md § Frontmatter, § Deletion): a `deleted` value with no sensible + /// date reading still tombstones — the user's intent to delete outranks the broken date. + @Test func malformedDeletedValueStillTombstonesLaneAndCard() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + try fixture.index("", "schema: 1\n") + try fixture.index("lane-1", "schema: 1\norder: 1024\ndeleted: yesterday\n") + try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\ndeleted: yesterday\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + let lane = try #require(result.model.lanes.first { $0.id.rawValue == "lane-1" }) + #expect(lane.deleted == .malformed(raw: "yesterday")) + #expect(lane.isDeleted) + + let card = try #require(lane.cards.first { $0.id.rawValue == "card-1" }) + #expect(card.deleted == .malformed(raw: "yesterday")) + #expect(card.isDeleted) + } + @Test func tiesAreBrokenByFolderNameNotTitle() throws { let fixture = try BoardFixture() defer { fixture.tearDown() } diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index 40f4d44..395a39c 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -345,11 +345,26 @@ struct FrontmatterLenientFieldTests { #expect(try document("width: 1").width == .valid(1)) } + /// A scalar of the wrong YAML type still has a sensible string reading — it coerces to the + /// source text the author typed (01-storage-format.md § Frontmatter). Only a sequence or + /// mapping — no scalar to read at all — is malformed. + @Test func scalarsOfTheWrongTypeCoerceToTheirSourceText() throws { + #expect(try document("title: 2048").title == .valid("2048")) + #expect(try document("title: true").title == .valid("true")) + #expect(try document("background: 42").background == .valid("42")) + #expect(try document("iconColor: true").iconColor == .valid("true")) + #expect(try document("icon: 2026-07-26T16:41:38Z").icon == .valid("2026-07-26T16:41:38Z")) + } + + @Test func quotedStringLenientValuesAreUnaffectedByCoercion() throws { + #expect(try document("title: \"2048\"").title == .valid("2048")) + #expect(try document("title: \"true\"").title == .valid("true")) + #expect(try document("title: My Board").title == .valid("My Board")) + } + @Test func malformedLenientValuesArePreservedVerbatimAndDoNotThrow() throws { #expect(try document("background: [red, blue]").background == .malformed(raw: "[red, blue]")) - #expect(try document("background: 42").background == .malformed(raw: "42")) #expect(try document("icon: {a: 1}").icon == .malformed(raw: "{a: 1}")) - #expect(try document("iconColor: true").iconColor == .malformed(raw: "true")) #expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]")) } @@ -361,6 +376,17 @@ struct FrontmatterLenientFieldTests { #expect(try document("schema: 1").width == .missing) } + /// A string or double with an exact integer reading ≥ 1 coerces; a fractional reading, + /// zero, a negative, or non-numeric text still has none. + @Test func widthCoercesStringsAndWholeNumberDoubles() throws { + #expect(try document("width: \"2\"").width == .valid(2)) + #expect(try document("width: 2.0").width == .valid(2)) + #expect(try document("width: 2.7").width == .malformed(raw: "2.7")) + #expect(try document("width: 0").width == .malformed(raw: "0")) + #expect(try document("width: -1").width == .malformed(raw: "-1")) + #expect(try document("width: banana").width == .malformed(raw: "banana")) + } + @Test func malformedLenientValuesStillRoundTrip() throws { let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 0\nicon: {a: 1}\n---\nbody\n" let document = try FrontmatterDocument.parse(text) @@ -378,6 +404,13 @@ struct FrontmatterLenientFieldTests { #expect(try document("schema: 1").deleted == .missing) #expect(try document("created: never").created == .malformed(raw: "never")) } + + /// A quoted ISO-8601 string is not YAML's implicit timestamp type — it parses as `.string` + /// — but still coerces to a valid date (01-storage-format.md § Frontmatter). + @Test func quotedISO8601StringCoercesToAValidDate() throws { + let expected = Date(timeIntervalSince1970: 1_767_323_045) // 2026-01-02T03:04:05Z + #expect(try document("created: \"2026-01-02T03:04:05Z\"").created == .valid(expected)) + } } // MARK: - Surgical edits