diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index fdc8f25..6196d05 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -46,11 +46,16 @@ extension FrontmatterDocument { 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 { read(FrontmatterKeys.order) { value, _ in switch value { case let .int(value): Double(value) - case let .double(value): value + case let .double(value): value.isFinite ? value : nil default: nil } } @@ -67,13 +72,16 @@ extension FrontmatterDocument { public var icon: FieldValue { read(FrontmatterKeys.icon, Self.string) } public var iconColor: FieldValue { read(FrontmatterKeys.iconColor, Self.string) } - /// 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. + /// Width multiplier. An exact-integer reading — from an int, a double, or a numeric string — + /// always coerces: at or above 1 to itself (`"2"`, `2.0` → `2`), below 1 to 1 (**ranges are + /// 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 { read(FrontmatterKeys.width) { value, _ in 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 .string(text): Double(text).flatMap(Self.exactIntWidth) default: nil @@ -109,11 +117,14 @@ extension FrontmatterDocument { } } - /// An integer-valued double or numeric string ≥ 1 coerces; anything else (fractional, - /// non-numeric, out of `Int` range) has no sensible width reading. + /// An integer-valued double or numeric string coerces — below 1 to 1, at or above 1 to the + /// 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? { - guard value.truncatingRemainder(dividingBy: 1) == 0, value >= 1, value <= Double(Int.max) else { return nil } - return Int(value) + guard value.truncatingRemainder(dividingBy: 1) == 0, value <= Double(Int.max) else { return nil } + return value >= 1 ? Int(value) : 1 } /// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar, diff --git a/Kanban/UI/Board/LaneLayoutMath.swift b/Kanban/UI/Board/LaneLayoutMath.swift index 146b4ab..320e5bb 100644 --- a/Kanban/UI/Board/LaneLayoutMath.swift +++ b/Kanban/UI/Board/LaneLayoutMath.swift @@ -50,13 +50,15 @@ enum LaneLayoutMath { /// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1 /// otherwise. /// - /// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key, a - /// non-numeric value, a fraction, a zero or a negative all arrive here as `.missing` or - /// `.malformed` and render as one unit — the bytes on disk are left exactly as the author wrote - /// them until the user actually changes the width, at which point the Writer replaces them with - /// an integer (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side - /// already refuses anything below 1, and this function is the single place the rest of the UI - /// asks "how many units does this lane span". + /// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key or + /// a non-numeric/fractional value arrives here as `.missing` or `.malformed` and renders as + /// one unit, while an exact-integer reading below 1 (zero, negative) is no longer malformed at + /// all — it coerces to 1 at the read side (**ranges are part of the sensible reading**, + /// settled). Either way the bytes on disk are left exactly as the author wrote them until the + /// user actually changes the width, at which point the Writer replaces them with an integer + /// (`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 { max(1, lane.width.value ?? 1) } diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index f850607..f8beb2c 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -241,6 +241,33 @@ struct BoardLoaderStrayTests { #expect(result.model.lanes.map(\.id.rawValue) == [realLane]) #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, @@ -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 { let fixture = try BoardFixture() defer { fixture.tearDown() } diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index bedc122..f9f8b53 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -1382,6 +1382,46 @@ struct BoardWriterCopyTests { #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 /// the same `Date` for the whole tree. @Test func bornStampsCreatedAndModifiedFreshAtEveryLevel() throws { diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index 1bde83b..be5f3cf 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -525,6 +525,16 @@ struct FrontmatterStrictFieldTests { #expect(try document("order: \"1024\"").order == .malformed(raw: "\"1024\"")) #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 @@ -593,27 +603,39 @@ struct FrontmatterLenientFieldTests { #expect(try document("title: [a, b]").title == .malformed(raw: "[a, b]")) } - @Test func widthIsLenientForAnythingButAnIntegerAtLeastOne() throws { - #expect(try document("width: 0").width == .malformed(raw: "0")) - #expect(try document("width: -3").width == .malformed(raw: "-3")) + /// Only a fractional or non-numeric reading has no sensible width at all — a sequence, + /// mapping, or scalar with no integer reading whatsoever stays malformed and renders as the + /// default 1. + @Test func widthIsMalformedOnlyForFractionalOrNonNumericReadings() throws { #expect(try document("width: 1.5").width == .malformed(raw: "1.5")) #expect(try document("width: wide").width == .malformed(raw: "wide")) #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. + /// **Ranges are part of the sensible reading** (01-storage-format.md § Frontmatter, settled): + /// 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 { #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 text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n" let document = try FrontmatterDocument.parse(text) #expect(document.serialized() == text) #expect(document.background.isMalformed) diff --git a/KanbanTests/LaneLayoutMathTests.swift b/KanbanTests/LaneLayoutMathTests.swift index fb2c259..2411fd4 100644 --- a/KanbanTests/LaneLayoutMathTests.swift +++ b/KanbanTests/LaneLayoutMathTests.swift @@ -139,13 +139,22 @@ struct LaneDisplayUnitsTests { #expect(missing.width.isMissing) #expect(LaneLayoutMath.displayUnits(of: missing) == 1) - // Each of these stays `.malformed` on the model — the bytes are preserved, not corrected — - // and renders as 1 (01-storage-format.md § Frontmatter's lenient-field rule). - for raw in ["wide", "0", "-3", "1.5"] { + // A fraction or non-numeric text has no integer reading at all — stays `.malformed` on + // the model (bytes preserved, not corrected) and renders as 1. + for raw in ["wide", "1.5"] { let lane = try lane(width: raw) #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") } + + // 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")