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
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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<Value: Sendable & Equatable>: 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<Int> {
|
||||
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<Double> {
|
||||
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<String> { read(FrontmatterKeys.title, Self.string) }
|
||||
public var background: FieldValue<String> { read(FrontmatterKeys.background, Self.string) }
|
||||
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
|
||||
public var iconColor: FieldValue<String> { 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<Int> {
|
||||
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<Date> { read(FrontmatterKeys.created, Self.date) }
|
||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified, Self.date) }
|
||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted, Self.date) }
|
||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||
public var deleted: FieldValue<Date> { 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<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
|
||||
|
||||
// MARK: -
|
||||
|
||||
private func read<Value>(_ key: String, _ transform: (YAMLValue) -> Value?) -> FieldValue<Value> {
|
||||
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
||||
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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user