Realign read-side rules — width range coercion, finite order, symlink pins

The design corpus ratified that ranges are part of a sensible reading:
an exact-integer width below 1 now coerces to 1 read-side (bytes
untouched) instead of reading as malformed — the width division must
never see a zero or negative unit — while a non-finite order (.nan,
.inf) is now the same loud malformed-order rejection as a non-numeric
one, guarded at the single point where the double arrives so loader
and Writer inherit it together. The symlink-never-traversed rule
turned out to be already enforced (the loader has filtered symlinks
ahead of the directory check since the first commit); it and the
copy-preserves-the-link-verbatim behavior are now pinned by tests,
alongside the two hostile shapes the corpus names (width: 0,
order: .nan). Five new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 14:21:03 -04:00
parent b4c90838b4
commit bea6d02d1d
6 changed files with 155 additions and 27 deletions
+20 -9
View File
@@ -46,11 +46,16 @@ extension FrontmatterDocument {
read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } } read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } }
} }
/// A non-finite reading (`.nan`, `.inf`) has no place in the total order the tie-break and
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) it is the same loud
/// malformed-input rejection as a non-numeric value, not a `.valid(Double.nan)` silently
/// poisoning every comparison downstream. An `Int` reading is always finite, so only the
/// `.double` case needs the check.
public var order: FieldValue<Double> { public var order: FieldValue<Double> {
read(FrontmatterKeys.order) { value, _ in read(FrontmatterKeys.order) { value, _ in
switch value { switch value {
case let .int(value): Double(value) case let .int(value): Double(value)
case let .double(value): value case let .double(value): value.isFinite ? value : nil
default: nil default: nil
} }
} }
@@ -67,13 +72,16 @@ extension FrontmatterDocument {
public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) } public var icon: FieldValue<String> { read(FrontmatterKeys.icon, Self.string) }
public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) } public var iconColor: FieldValue<String> { read(FrontmatterKeys.iconColor, Self.string) }
/// Width multiplier. An int stays; a string or double with an exact integer reading 1 /// Width multiplier. An exact-integer reading from an int, a double, or a numeric string
/// coerces (`"2"`, `2.0` `2`). Everything else zero, negative, fractional, non-numeric, /// always coerces: at or above 1 to itself (`"2"`, `2.0` `2`), below 1 to 1 (**ranges are
/// bool, a sequence/mapping is malformed and renders as the default 1. /// part of the sensible reading**, 01-storage-format.md § Frontmatter, settled the table's
/// " 1" is a validity bound on the coerced reading, not a gate on which readings are
/// sensible). Everything else fractional, non-numeric, bool, a sequence/mapping has no
/// integer reading at all and is malformed, rendering as the default 1.
public var width: FieldValue<Int> { public var width: FieldValue<Int> {
read(FrontmatterKeys.width) { value, _ in read(FrontmatterKeys.width) { value, _ in
switch value { switch value {
case let .int(value): value >= 1 ? value : nil case let .int(value): value >= 1 ? value : 1
case let .double(value): Self.exactIntWidth(value) case let .double(value): Self.exactIntWidth(value)
case let .string(text): Double(text).flatMap(Self.exactIntWidth) case let .string(text): Double(text).flatMap(Self.exactIntWidth)
default: nil default: nil
@@ -109,11 +117,14 @@ extension FrontmatterDocument {
} }
} }
/// An integer-valued double or numeric string 1 coerces; anything else (fractional, /// An integer-valued double or numeric string coerces below 1 to 1, at or above 1 to the
/// non-numeric, out of `Int` range) has no sensible width reading. /// value itself; a fractional reading, a non-numeric one, or one outside `Int` range on the
/// high end has no sensible width reading at all. The high-end guard is what makes `Int(value)`
/// safe below; there is no matching low-end guard because anything below 1 short-circuits to
/// the literal `1` without ever converting the (possibly enormous negative) double to `Int`.
private static func exactIntWidth(_ value: Double) -> Int? { private static func exactIntWidth(_ value: Double) -> Int? {
guard value.truncatingRemainder(dividingBy: 1) == 0, value >= 1, value <= Double(Int.max) else { return nil } guard value.truncatingRemainder(dividingBy: 1) == 0, value <= Double(Int.max) else { return nil }
return Int(value) return value >= 1 ? Int(value) : 1
} }
/// A quoted timestamp reads the same as an unquoted one same YAML 1.1 timestamp grammar, /// A quoted timestamp reads the same as an unquoted one same YAML 1.1 timestamp grammar,
+9 -7
View File
@@ -50,13 +50,15 @@ enum LaneLayoutMath {
/// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1 /// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1
/// otherwise. /// otherwise.
/// ///
/// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key, a /// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key or
/// non-numeric value, a fraction, a zero or a negative all arrive here as `.missing` or /// a non-numeric/fractional value arrives here as `.missing` or `.malformed` and renders as
/// `.malformed` and render as one unit the bytes on disk are left exactly as the author wrote /// one unit, while an exact-integer reading below 1 (zero, negative) is no longer malformed at
/// them until the user actually changes the width, at which point the Writer replaces them with /// all it coerces to 1 at the read side (**ranges are part of the sensible reading**,
/// an integer (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side /// settled). Either way the bytes on disk are left exactly as the author wrote them until the
/// already refuses anything below 1, and this function is the single place the rest of the UI /// user actually changes the width, at which point the Writer replaces them with an integer
/// asks "how many units does this lane span". /// (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side already never
/// produces anything below 1, and this function is the single place the rest of the UI asks
/// "how many units does this lane span".
static func displayUnits(of lane: Lane) -> Int { static func displayUnits(of lane: Lane) -> Int {
max(1, lane.width.value ?? 1) max(1, lane.width.value ?? 1)
} }
+44
View File
@@ -241,6 +241,33 @@ struct BoardLoaderStrayTests {
#expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #expect(result.model.lanes.map(\.id.rawValue) == [realLane])
#expect(result.warnings.isEmpty) #expect(result.warnings.isEmpty)
} }
/// The identity predicate is shape-only "identity-shaped name or not" (01-storage-format.md
/// § Fractal layout Rules, "Symlinks are never traversed", settled). This pins the case the
/// previous test's non-UUID name doesn't: a symlink whose *name itself* would pass
/// `isUUIDShaped` and which points at a real directory must still be excluded before that
/// name shape is ever consulted `directoryCandidates` filters on `isSymbolicLink` ahead of
/// `isDirectory`, so a link is never mistaken for the directory it points to, UUID-shaped name
/// or not.
@Test func uuidShapedSymlinkToADirectoryIsTreatedAsStrayNotFollowed() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let realLane = uuidFolderName()
let linkedName = uuidFolderName()
try fixture.index("", "schema: 1\n")
let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n")
try FileManager.default.createSymbolicLink(
at: fixture.root.appendingPathComponent(linkedName),
withDestinationURL: realLaneURL
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [realLane])
#expect(!result.model.lanes.map(\.id.rawValue).contains(linkedName))
#expect(result.warnings.isEmpty)
}
} }
// MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout Rules, // MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout Rules,
@@ -728,6 +755,23 @@ struct BoardLoaderFailFastTests {
} }
} }
/// A non-finite `order` (`.nan`, `.inf`) is the same loud rejection as a non-numeric one
/// (01-storage-format.md § Frontmatter, settled) NaN has no place in the total order the
/// tie-break and midpoint math assume.
@Test func nonFiniteOrderOnUUIDLaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: .nan\n")
expectFailure(.malformedOrder(raw: ".nan"), path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func missingOrderOnUUIDCardThrows() throws { @Test func missingOrderOnUUIDCardThrows() throws {
let fixture = try BoardFixture() let fixture = try BoardFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
+40
View File
@@ -1382,6 +1382,46 @@ struct BoardWriterCopyTests {
#expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(card)").contains("attachments")) #expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(card)").contains("attachments"))
} }
/// A symlink surviving a copy as a symlink never resolved, never followed to its target's
/// bytes is `FileManager.copyItem`'s own default behavior (01-storage-format.md § Fractal
/// layout Rules, "Symlinks are never traversed": "Copy flows copy the link itself, never its
/// target"). Nothing in `copyItem` arranges this on purpose it falls out of leaning on
/// `FileManager.copyItem` for the whole subtree rather than reading files one by one so this
/// pins the behavior against a future change (a different copy mechanism, a Foundation
/// update) silently starting to dereference links instead.
@Test func copyItemPreservesASymlinkVerbatimInsideACopiedSubtree() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try board(fixture)
let cardPath = "A.kanban/\(Ident.lane1)/\(Ident.card1)"
try fixture.file("\(cardPath)/attachments/real.png", Data([0x89, 0x50, 0x4E, 0x47]))
try FileManager.default.createSymbolicLink(
atPath: fixture.url("\(cardPath)/attachments/linked.png").path,
withDestinationPath: "real.png"
)
let id = try BoardWriter.copyItem(
at: fixture.url("A.kanban/\(Ident.lane1)"),
toParent: fixture.url("A.kanban"),
order: nil,
stamps: .fork
)
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture)
let card = try #require(copied["Card One"])
let copiedLink = fixture.url("A.kanban/\(id.rawValue)/\(card)/attachments/linked.png")
let values = try copiedLink.resourceValues(forKeys: [.isSymbolicLinkKey])
#expect(values.isSymbolicLink == true)
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: copiedLink.path) == "real.png")
// The source link is untouched too a copy never touches what it reads from.
#expect(
try FileManager.default.destinationOfSymbolicLink(
atPath: fixture.url("\(cardPath)/attachments/linked.png").path
) == "real.png"
)
}
/// Template instantiation: born today, not forked `created` and `modified` both fresh, and /// Template instantiation: born today, not forked `created` and `modified` both fresh, and
/// the same `Date` for the whole tree. /// the same `Date` for the whole tree.
@Test func bornStampsCreatedAndModifiedFreshAtEveryLevel() throws { @Test func bornStampsCreatedAndModifiedFreshAtEveryLevel() throws {
+30 -8
View File
@@ -525,6 +525,16 @@ struct FrontmatterStrictFieldTests {
#expect(try document("order: \"1024\"").order == .malformed(raw: "\"1024\"")) #expect(try document("order: \"1024\"").order == .malformed(raw: "\"1024\""))
#expect(try document("order: [1]").order == .malformed(raw: "[1]")) #expect(try document("order: [1]").order == .malformed(raw: "[1]"))
} }
/// NaN has no place in the total order the tie-break and midpoint math assume
/// (01-storage-format.md § Frontmatter, settled): a non-finite reading is the same loud
/// malformed-input rejection as a non-numeric one, never a silently `.valid(Double.nan)`.
@Test func nonFiniteOrderIsMalformedNotValid() throws {
#expect(try document("order: .nan").order == .malformed(raw: ".nan"))
#expect(try document("order: .inf").order == .malformed(raw: ".inf"))
#expect(try document("order: +.inf").order == .malformed(raw: "+.inf"))
#expect(try document("order: -.inf").order == .malformed(raw: "-.inf"))
}
} }
// MARK: - Lenient fields // MARK: - Lenient fields
@@ -593,27 +603,39 @@ struct FrontmatterLenientFieldTests {
#expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]")) #expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]"))
} }
@Test func widthIsLenientForAnythingButAnIntegerAtLeastOne() throws { /// Only a fractional or non-numeric reading has no sensible width at all a sequence,
#expect(try document("width: 0").width == .malformed(raw: "0")) /// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the
#expect(try document("width: -3").width == .malformed(raw: "-3")) /// default 1.
@Test func widthIsMalformedOnlyForFractionalOrNonNumericReadings() throws {
#expect(try document("width: 1.5").width == .malformed(raw: "1.5")) #expect(try document("width: 1.5").width == .malformed(raw: "1.5"))
#expect(try document("width: wide").width == .malformed(raw: "wide")) #expect(try document("width: wide").width == .malformed(raw: "wide"))
#expect(try document("schema: 1").width == .missing) #expect(try document("schema: 1").width == .missing)
} }
/// A string or double with an exact integer reading 1 coerces; a fractional reading, /// **Ranges are part of the sensible reading** (01-storage-format.md § Frontmatter, settled):
/// zero, a negative, or non-numeric text still has none. /// an exact-integer reading below 1 from an int, a double, or a numeric string coerces to
/// 1 rather than falling back to malformed. The table's " 1" is a validity bound on the
/// coerced value, not a gate on which readings count as sensible.
@Test func anIntegerWidthBelowOneCoercesToOne() throws {
#expect(try document("width: 0").width == .valid(1))
#expect(try document("width: -3").width == .valid(1))
#expect(try document("width: \"0\"").width == .valid(1))
#expect(try document("width: \"-3\"").width == .valid(1))
#expect(try document("width: -1.0").width == .valid(1))
}
/// A string or double with an exact integer reading coerces below 1 to 1 (see
/// `anIntegerWidthBelowOneCoercesToOne`), at or above 1 to itself; a fractional reading or
/// non-numeric text still has none.
@Test func widthCoercesStringsAndWholeNumberDoubles() throws { @Test func widthCoercesStringsAndWholeNumberDoubles() throws {
#expect(try document("width: \"2\"").width == .valid(2)) #expect(try document("width: \"2\"").width == .valid(2))
#expect(try document("width: 2.0").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: 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")) #expect(try document("width: banana").width == .malformed(raw: "banana"))
} }
@Test func malformedLenientValuesStillRoundTrip() throws { @Test func malformedLenientValuesStillRoundTrip() throws {
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 0\nicon: {a: 1}\n---\nbody\n" let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n"
let document = try FrontmatterDocument.parse(text) let document = try FrontmatterDocument.parse(text)
#expect(document.serialized() == text) #expect(document.serialized() == text)
#expect(document.background.isMalformed) #expect(document.background.isMalformed)
+12 -3
View File
@@ -139,13 +139,22 @@ struct LaneDisplayUnitsTests {
#expect(missing.width.isMissing) #expect(missing.width.isMissing)
#expect(LaneLayoutMath.displayUnits(of: missing) == 1) #expect(LaneLayoutMath.displayUnits(of: missing) == 1)
// Each of these stays `.malformed` on the model the bytes are preserved, not corrected // A fraction or non-numeric text has no integer reading at all stays `.malformed` on
// and renders as 1 (01-storage-format.md § Frontmatter's lenient-field rule). // the model (bytes preserved, not corrected) and renders as 1.
for raw in ["wide", "0", "-3", "1.5"] { for raw in ["wide", "1.5"] {
let lane = try lane(width: raw) let lane = try lane(width: raw)
#expect(lane.width.isMalformed, "width: \(raw) should stay malformed rather than coerce") #expect(lane.width.isMalformed, "width: \(raw) should stay malformed rather than coerce")
#expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit") #expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit")
} }
// An exact integer below 1 is a **different** case (01-storage-format.md § Frontmatter,
// "ranges are part of the sensible reading", settled): it coerces to `.valid(1)`, not
// malformed same on-screen result, different model reading.
for raw in ["0", "-3"] {
let lane = try lane(width: raw)
#expect(lane.width == .valid(1), "width: \(raw) should coerce to 1, not stay malformed")
#expect(LaneLayoutMath.displayUnits(of: lane) == 1, "width: \(raw) should render as one unit")
}
} }
@Test("The strip's unit total is the sum over the lanes it is given") @Test("The strip's unit total is the sum over the lanes it is given")