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:
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user