The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
272 lines
14 KiB
Swift
272 lines
14 KiB
Swift
import Foundation
|
|
|
|
/// Pure gapped-fractional-ordering math: append, insert, midpoint, and the
|
|
/// renumber target, plus the display-order tie-break rule shared by the
|
|
/// loader and the writer. No filesystem or model dependency — see
|
|
/// DESIGN/01-storage-format.md § Ordering, Deletion.
|
|
enum Ranks: Sendable {
|
|
|
|
/// Gap between successive ranks on append/head-insert, and the multiple
|
|
/// used by `renumbered(count:)`.
|
|
private static let gap: Double = 1024
|
|
|
|
// MARK: - Append / insert
|
|
|
|
/// Rank for a new item appended after all visible siblings.
|
|
/// An empty lane's first item lands at `1024` (the board convention).
|
|
static func append(toVisible orders: some Sequence<Double>) -> Double {
|
|
(orders.max() ?? 0) + gap
|
|
}
|
|
|
|
/// Rank for a new item inserted before all visible siblings.
|
|
/// An empty lane's first item lands at `1024` (the board convention).
|
|
static func insertAtHead(ofVisible orders: some Sequence<Double>) -> Double {
|
|
guard let minOrder = orders.min() else { return gap }
|
|
return minOrder - gap
|
|
}
|
|
|
|
/// Rank strictly between `a` and `b`, or `nil` if no `Double` is
|
|
/// representable between them — including when `a == b` (a duplicate
|
|
/// order, the tie case). Never returns a value ≤ min(a, b) or
|
|
/// ≥ max(a, b).
|
|
static func midpoint(between a: Double, and b: Double) -> Double? {
|
|
let lower = min(a, b)
|
|
let upper = max(a, b)
|
|
guard lower < upper else { return nil }
|
|
let mid = lower + (upper - lower) / 2
|
|
guard mid > lower, mid < upper else { return nil }
|
|
return mid
|
|
}
|
|
|
|
/// The rank an item takes when it lands at display position `index` among `orders` — the
|
|
/// visible siblings it is joining, **in display order and with the item itself already
|
|
/// excluded**.
|
|
///
|
|
/// One function for the three cases every insertion gesture has (a lane drag's release, a
|
|
/// drop between two cards, ⌘N's after-the-anchor position), so no call site re-derives which
|
|
/// of `insertAtHead`/`midpoint`/`append` its position calls for:
|
|
///
|
|
/// - at or before the head → `insertAtHead(ofVisible:)`;
|
|
/// - at or past the end (an empty `orders` included) → `append(toVisible:)`;
|
|
/// - between two siblings → their `midpoint`.
|
|
///
|
|
/// **`nil` means the gap is exhausted, not that the insertion is illegal**: `midpoint` returns
|
|
/// no value when two neighbours are adjacent `Double`s or share an order (the duplicate-order
|
|
/// tie). That is the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to
|
|
/// compact and ask again — never something to paper over with an arbitrary rank, which would
|
|
/// silently reorder the board.
|
|
static func insertionRank(amongVisible orders: [Double], at index: Int) -> Double? {
|
|
if orders.isEmpty || index >= orders.count { return append(toVisible: orders) }
|
|
if index <= 0 { return insertAtHead(ofVisible: orders) }
|
|
return midpoint(between: orders[index - 1], and: orders[index])
|
|
}
|
|
|
|
/// The `count` ranks a **contiguous run** takes when it lands at display position `index`
|
|
/// among `orders` — `insertionRank(amongVisible:at:)` for a multi-drag, whose whole set inserts
|
|
/// at one spot in preserved order (04-interactions.md ▸ Drag and drop, DRAG-REORDER.md §
|
|
/// Multi-drag).
|
|
///
|
|
/// `orders` is the visible siblings **in display order with the run itself already excluded** —
|
|
/// the resting layout's convention, the same one the geometry's index is counted in.
|
|
///
|
|
/// The three cases mirror the single-rank twin, spread over `count` values:
|
|
///
|
|
/// - at or before the head → `count` whole gaps *below* the first sibling, ascending;
|
|
/// - at or past the end (an empty `orders` included) → `count` whole gaps above the last;
|
|
/// - between two siblings → `count` evenly spaced points strictly inside their interval.
|
|
///
|
|
/// **`nil` means the gap is exhausted, not that the insertion is illegal** — the interior case
|
|
/// fails when the two neighbours are close enough that `count` distinct, strictly increasing
|
|
/// `Double`s do not fit between them (adjacent doubles, or the duplicate-order tie). That is
|
|
/// the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to compact and
|
|
/// ask again, exactly as an exhausted midpoint is everywhere else.
|
|
static func insertionRanks(amongVisible orders: [Double], at index: Int, count: Int) -> [Double]? {
|
|
guard count > 0 else { return [] }
|
|
if orders.isEmpty || index >= orders.count {
|
|
let base = orders.max() ?? 0
|
|
return (1...count).map { base + gap * Double($0) }
|
|
}
|
|
if index <= 0 {
|
|
let base = orders.min() ?? 0
|
|
// Ascending, and every value below `base`: the deepest is `count` gaps down.
|
|
return (1...count).map { base - gap * Double(count - $0 + 1) }
|
|
}
|
|
|
|
let lower = orders[index - 1]
|
|
let upper = orders[index]
|
|
guard lower < upper else { return nil }
|
|
let step = (upper - lower) / Double(count + 1)
|
|
var ranks: [Double] = []
|
|
var previous = lower
|
|
for position in 1...count {
|
|
let rank = lower + step * Double(position)
|
|
// Every rank must sit strictly inside the interval *and* strictly above the last one:
|
|
// at the precision floor the arithmetic silently collapses onto a neighbour, and a
|
|
// duplicate rank would hand display order to the folder-name tie-break.
|
|
guard rank > previous, rank < upper else { return nil }
|
|
ranks.append(rank)
|
|
previous = rank
|
|
}
|
|
return ranks
|
|
}
|
|
|
|
/// `count` fresh ranks, whole multiples of 1024 in ascending order
|
|
/// (1024, 2048, …) — the renumber target when midpoint precision is
|
|
/// exhausted. Deterministic by construction; the writer applies these,
|
|
/// in order, to the current visible siblings in display order.
|
|
static func renumbered(count: Int) -> [Double] {
|
|
guard count > 0 else { return [] }
|
|
return (1...count).map { Double($0) * gap }
|
|
}
|
|
|
|
// MARK: - The append-at-end reading
|
|
|
|
/// **Every sibling's rank, with the order-less ones read as append-at-end** — the whole of the
|
|
/// optional-`order` ruling's read side (01-storage-format.md § Ordering, re-ruled 2026-07-31:
|
|
/// "Missing or unusable `order` reads as append-at-end … order-less items sort after every
|
|
/// ordered sibling, among themselves by the folder-name tie-break — deterministic with zero
|
|
/// sibling reads, which is what makes the minimum agent card legal").
|
|
///
|
|
/// - Parameter stored: the `order` the file actually carries, or `nil` where it carries none the
|
|
/// loader can use — missing, explicitly null, non-numeric, non-finite. The four shapes are one
|
|
/// answer here on purpose: the reading is stated over *usability*, not over which way a value
|
|
/// failed to be usable (`IntegrityRules.resolvedOrder` is where the four are told apart, for
|
|
/// the coerce-tier record).
|
|
/// - Parameter name: the folder name — the tie-break the design states this ordering in.
|
|
/// - Returns: one rank per sibling, **positionally aligned with `siblings`** (never reordered:
|
|
/// callers sort afterwards, through `sortedForDisplay`, exactly as they always did).
|
|
///
|
|
/// **The materialized ranks are `append`'s own arithmetic**, and that is the load-bearing
|
|
/// property rather than a convenience: the k-th order-less sibling reads as `max + 1024·k` over
|
|
/// the ranks actually written down, which is exactly where `append(toVisible:)` would have put it
|
|
/// had it been filed by the app. So the reading a board *renders* is a rank ladder the Writer can
|
|
/// stamp verbatim — which is what the on-touch and inline stamps do
|
|
/// (`BoardWriter.stampAppendAtEndOrders`), and why stamping one changes nothing on screen.
|
|
///
|
|
/// An empty ranked set bases at `0`, so a container of nothing but order-less items reads
|
|
/// `1024, 2048, …` — the same board convention `append` gives an empty container's first child.
|
|
///
|
|
/// **Accepted cost, stated by the ruling**: two order-less siblings sort by folder name rather
|
|
/// than by intent until something touches them.
|
|
static func resolvedOrders<T>(
|
|
of siblings: [T],
|
|
stored: (T) -> Double?,
|
|
name: (T) -> String
|
|
) -> [Double] {
|
|
let storedOrders = siblings.map(stored)
|
|
var resolved = storedOrders.map { $0 ?? 0 }
|
|
let orderless = storedOrders.indices.filter { storedOrders[$0] == nil }
|
|
guard !orderless.isEmpty else { return resolved }
|
|
|
|
let base = storedOrders.compactMap { $0 }.max() ?? 0
|
|
let queue = orderless.sorted { name(siblings[$0]) < name(siblings[$1]) }
|
|
for (step, index) in queue.enumerated() {
|
|
resolved[index] = base + gap * Double(step + 1)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - Display order
|
|
|
|
/// Ascending display order: primary key `order`, ties broken by folder
|
|
/// name (lexicographic) for deterministic rendering. Shared by the
|
|
/// loader and the writer so both apply the same tie-break rule.
|
|
///
|
|
/// An order-less sibling reaches here already carrying its append-at-end
|
|
/// reading (`resolvedOrders(of:stored:name:)`), so this comparator needs no
|
|
/// case for one: "after every ordered sibling, then by folder name" *is*
|
|
/// this rule applied to the materialized ranks.
|
|
static func isOrderedForDisplay<T>(
|
|
_ lhs: T, before rhs: T,
|
|
order: (T) -> Double, name: (T) -> String
|
|
) -> Bool {
|
|
let lhsOrder = order(lhs)
|
|
let rhsOrder = order(rhs)
|
|
return lhsOrder != rhsOrder ? lhsOrder < rhsOrder : name(lhs) < name(rhs)
|
|
}
|
|
|
|
/// Sorts siblings into display order — ascending `order`, ties broken by
|
|
/// folder name.
|
|
static func sortedForDisplay<T>(
|
|
_ items: [T],
|
|
order: (T) -> Double,
|
|
name: (T) -> String
|
|
) -> [T] {
|
|
items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) }
|
|
}
|
|
|
|
// MARK: - The trash's own order
|
|
|
|
/// The trash's order: **`modified` descending**, ties broken by title
|
|
/// (case-insensitive), then folder name (01-storage-format.md § Deletion,
|
|
/// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md §
|
|
/// Trash: "the trash sorts by `modified` descending … Ties break by title
|
|
/// (case-insensitive), then folder name — the deterministic tail").
|
|
///
|
|
/// **`order` is not consulted at all in here**, which is the whole of the
|
|
/// ruling: a delete is a pure folder move plus the stamp, and the item's
|
|
/// `order` key rides along untouched for its eventual restore. The trash is
|
|
/// the one container in the app whose sequence is not a rank sequence.
|
|
///
|
|
/// **An undated entry sorts after every dated one** — the comment thread's
|
|
/// own rule for a missing `created` ("ties and missing/malformed … sort
|
|
/// after dated siblings"), read one container over: a stamp the app always
|
|
/// writes is missing only on a hand-made or foreign entry, and a missing
|
|
/// value must never outrank a real deletion. The tail then separates them
|
|
/// exactly as it separates two equal stamps.
|
|
///
|
|
/// Titles compare with `localizedCaseInsensitiveCompare`, the same
|
|
/// comparison the rest of the app's user-facing sorting uses, and an
|
|
/// untitled entry compares as the empty string — it sorts first among its
|
|
/// stamp-mates, which is deterministic and is all the tail owes.
|
|
static func isOrderedForTrash<T>(
|
|
_ lhs: T, before rhs: T,
|
|
modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String
|
|
) -> Bool {
|
|
let lhsModified = modified(lhs)
|
|
let rhsModified = modified(rhs)
|
|
if lhsModified != rhsModified {
|
|
// Newest first; an absent stamp is older than every present one.
|
|
switch (lhsModified, rhsModified) {
|
|
case let (.some(left), .some(right)): return left > right
|
|
case (.some, .none): return true
|
|
case (.none, .some): return false
|
|
case (.none, .none): break
|
|
}
|
|
}
|
|
let lhsTitle = title(lhs) ?? ""
|
|
let rhsTitle = title(rhs) ?? ""
|
|
let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle)
|
|
guard comparison == .orderedSame else { return comparison == .orderedAscending }
|
|
return name(lhs) < name(rhs)
|
|
}
|
|
|
|
/// Sorts the trash's entries into column order — `isOrderedForTrash`'s rule,
|
|
/// applied by the loader to each kind's array and by `BoardModel.trashEntries`
|
|
/// to the merged sequence, so the two can never disagree about what "the row
|
|
/// below this one" is (03-board-ui.md § Trash: "The merged order is one
|
|
/// derivation").
|
|
static func sortedForTrash<T>(
|
|
_ items: [T],
|
|
modified: (T) -> Date?,
|
|
title: (T) -> String?,
|
|
name: (T) -> String
|
|
) -> [T] {
|
|
items.sorted { isOrderedForTrash($0, before: $1, modified: modified, title: title, name: name) }
|
|
}
|
|
|
|
// MARK: - Tombstone exclusion
|
|
|
|
/// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are
|
|
/// inert to ordering — appends operate on visible siblings only.
|
|
static func append(toVisible items: some Sequence<(order: Double, isDeleted: Bool)>) -> Double {
|
|
append(toVisible: items.filter { !$0.isDeleted }.map { $0.order })
|
|
}
|
|
|
|
/// `insertAtHead(ofVisible:)`, ignoring tombstoned siblings. Tombstones
|
|
/// are inert to ordering — inserts operate on visible siblings only.
|
|
static func insertAtHead(ofVisible items: some Sequence<(order: Double, isDeleted: Bool)>) -> Double {
|
|
insertAtHead(ofVisible: items.filter { !$0.isDeleted }.map { $0.order })
|
|
}
|
|
}
|