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:
2026-07-26 15:47:33 -04:00
parent a6f4960492
commit 8e7c565e07
4 changed files with 107 additions and 22 deletions
+49 -18
View File
@@ -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