Lanes delete into the trash — storage, loader, writer, and undo
Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -46,30 +46,34 @@ import os
|
||||
///
|
||||
/// ## The trash is a container, not a level
|
||||
///
|
||||
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly
|
||||
/// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no
|
||||
/// `index.md` of its own". The walk therefore treats it as a second card container beside the
|
||||
/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane
|
||||
/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands
|
||||
/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and
|
||||
/// never warns; absent, the trash is simply empty.
|
||||
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
|
||||
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
|
||||
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
|
||||
/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the
|
||||
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
|
||||
/// warns; absent, the trash is simply empty.
|
||||
///
|
||||
/// **`kind:` decides what each entry is** (`IntegrityRules.trashKind`): depth defines meaning on the
|
||||
/// live board, but the trash is flat and an empty lane folder is shape-identical to a card folder.
|
||||
/// A card lands in `BoardModel.trash` parsed like any card; a lane lands in
|
||||
/// `BoardModel.trashedLanes` as an **opaque unit** — title, rank, and a count of the cards it holds
|
||||
/// — and its subtree is never walked into the snapshot (03-board-ui.md § Trash).
|
||||
///
|
||||
/// ## The migration window
|
||||
///
|
||||
/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on
|
||||
/// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane
|
||||
/// returns live with the key removed, a board-level key stays meaningless (ignored + logged).
|
||||
/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports
|
||||
/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated
|
||||
/// fix.
|
||||
/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card**
|
||||
/// is *migration input* — the card relocates into `.trash/` with the key removed. A key on a lane or
|
||||
/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes
|
||||
/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is
|
||||
/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found
|
||||
/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix.
|
||||
///
|
||||
/// **Until that write lands, such items still load through the retiring tombstone path** —
|
||||
/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a
|
||||
/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy",
|
||||
/// and the safe direction while the fix is still pending (it is deferred under any read-only lock,
|
||||
/// and can be pending for a whole session) is for nothing to silently vanish from view before its
|
||||
/// folder has actually moved. The window closes per board on the first successful migration write,
|
||||
/// after which no `deleted:` key exists to read and the flag is permanently `false`.
|
||||
/// **Until that write lands, such cards still load through the retiring tombstone path** — a
|
||||
/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate
|
||||
/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe
|
||||
/// direction while the fix is still pending (it is deferred under any read-only lock, and can be
|
||||
/// pending for a whole session) is for nothing to silently vanish from view before its folder has
|
||||
/// actually moved. The window closes per board on the first successful migration write.
|
||||
public enum BoardLoader: Sendable {
|
||||
|
||||
/// Schema version this app understands; anything higher fails fast
|
||||
@@ -281,7 +285,6 @@ public enum BoardLoader: Sendable {
|
||||
// and the key's removal are the store's, through the Writer.
|
||||
if card.isDeleted {
|
||||
defects.append(.legacyTombstone(LegacyTombstone(
|
||||
kind: .card,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: card.title.value
|
||||
@@ -292,14 +295,14 @@ public enum BoardLoader: Sendable {
|
||||
cards.append(card)
|
||||
}
|
||||
|
||||
// **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
|
||||
// § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
|
||||
// live with the key ignored — no migration machinery, no key-strip write, no notice
|
||||
// (backward compatibility deliberately not funded …; the key is inert, preserved verbatim
|
||||
// like any unhandled key, logged)". A warning, exactly like the board-level key: this was
|
||||
// ignored, it is staying exactly where it is, there is nothing to do.
|
||||
if !laneDocument.deleted.isMissing {
|
||||
defects.append(.legacyTombstone(LegacyTombstone(
|
||||
kind: .lane,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: nil,
|
||||
title: laneDocument.title.value
|
||||
)))
|
||||
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
||||
warn(.laneLevelDeletedIgnored(path: laneName))
|
||||
}
|
||||
|
||||
walkedLanes.append(WalkedLane(
|
||||
@@ -312,30 +315,86 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into.
|
||||
var trashEntries: [(id: ItemID, title: String?, order: Double)] = []
|
||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for cardURL in trashCandidates(in: boardRoot) {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = trashFolderName + "/" + cardName
|
||||
guard isUUIDShaped(cardName) else {
|
||||
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
||||
for entryURL in trashCandidates(in: boardRoot) {
|
||||
let entryName = entryURL.lastPathComponent
|
||||
let entryRelPath = trashFolderName + "/" + entryName
|
||||
guard isUUIDShaped(entryName) else {
|
||||
warn(.nonUUIDFolderIgnored(path: entryRelPath))
|
||||
continue
|
||||
}
|
||||
guard hasIndex(cardURL) else {
|
||||
warn(.missingIndex(path: cardRelPath))
|
||||
guard hasIndex(entryURL) else {
|
||||
warn(.missingIndex(path: entryRelPath))
|
||||
continue
|
||||
}
|
||||
let entry = try parseCard(at: cardURL, path: cardRelPath)
|
||||
noteCoercions(in: entry.document, at: cardRelPath + "/" + indexFileName)
|
||||
// The two kinds are validated identically — `schema` and `order` are required of both
|
||||
// (`IntegrityRules.requiresOrder`) — so the strict parse happens once, before the
|
||||
// discriminator, and a malformed entry fails fast whichever kind it turns out to be.
|
||||
let entryPath = entryRelPath + "/" + indexFileName
|
||||
let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
|
||||
let schema = try validatedSchema(in: document, path: entryPath)
|
||||
let order = try validatedOrder(in: document, path: entryPath)
|
||||
noteCoercions(in: document, at: entryPath)
|
||||
|
||||
// **The trash's discriminator, applied where the flat container needs it**
|
||||
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
||||
// outright, and only an unrecognized value or no key at all falls through to shape.
|
||||
// Reading the shape half is one directory listing, and only when the value did not
|
||||
// answer — see `looksLikeALaneFolder(_:)`.
|
||||
trashKinds[entry.id] = IntegrityRules.trashKind(
|
||||
kindValue: entry.document.kind.value,
|
||||
hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL)
|
||||
//
|
||||
// The children are listed at most once per entry and only where an answer needs them —
|
||||
// the shape fallback asks when `kind` did not, the lane arm asks for the count — so a
|
||||
// board of trashed cards written by this app pays for no directory reads at all.
|
||||
var listed: [URL]?
|
||||
func children() -> [URL] {
|
||||
if let listed { return listed }
|
||||
let found = identityShapedChildren(of: entryURL)
|
||||
listed = found
|
||||
return found
|
||||
}
|
||||
let kind = IntegrityRules.trashKind(
|
||||
kindValue: document.kind.value,
|
||||
hasIdentityShapedChildIndex: !children().isEmpty
|
||||
)
|
||||
trash.append(entry)
|
||||
let id = ItemID(rawValue: entryName)
|
||||
trashKinds[id] = kind
|
||||
trashEntries.append((id: id, title: document.title.value, order: order))
|
||||
|
||||
switch kind {
|
||||
case .lane:
|
||||
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
|
||||
// showing its title and held-card count). The count is the same listing the shape
|
||||
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and
|
||||
// a kindless one pays for none extra.
|
||||
trashedLanes.append(TrashedLane(
|
||||
id: id,
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
order: order,
|
||||
heldCards: children().count,
|
||||
document: document
|
||||
))
|
||||
case .card, .board:
|
||||
// `kind: board` never reaches here as itself — `trashKind` treats it as unrecognized
|
||||
// and answers by shape — so this arm is the card answer and nothing else.
|
||||
trash.append(Card(
|
||||
id: id,
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
deleted: document.deleted,
|
||||
background: document.background,
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
attachments: attachmentNames(in: entryURL),
|
||||
document: document
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// **The board-wide identity dedupe** (01-storage-format.md § Fractal layout ▸ Rules:
|
||||
@@ -348,10 +407,15 @@ public enum BoardLoader: Sendable {
|
||||
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
|
||||
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
|
||||
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
|
||||
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue })
|
||||
// The trash's own display order, **both kinds at once** — the column interleaves them by rank
|
||||
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
|
||||
// the two kinds are merged before the rule sees them rather than after.
|
||||
let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue })
|
||||
let verdict = dedupeIdentities(
|
||||
inBoardAt: boardRoot,
|
||||
lanes: orderedLanes,
|
||||
trash: orderedTrash,
|
||||
trash: orderedTrashEntries,
|
||||
historyRanker: historyRanker
|
||||
)
|
||||
|
||||
@@ -386,6 +450,7 @@ public enum BoardLoader: Sendable {
|
||||
template: boardDocument.value(for: templateKey),
|
||||
lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
|
||||
trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
||||
trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
||||
document: boardDocument
|
||||
)
|
||||
|
||||
@@ -430,7 +495,7 @@ public enum BoardLoader: Sendable {
|
||||
private static func dedupeIdentities(
|
||||
inBoardAt root: URL,
|
||||
lanes: [WalkedLane],
|
||||
trash: [Card],
|
||||
trash: [(id: ItemID, title: String?, order: Double)],
|
||||
historyRanker: IdentityHistoryRanker?
|
||||
) -> IntegrityRules.DedupeVerdict {
|
||||
typealias Container = IntegrityRules.IdentityOccurrence.Container
|
||||
@@ -458,7 +523,7 @@ public enum BoardLoader: Sendable {
|
||||
path: trashFolderName + "/" + entry.id.rawValue,
|
||||
name: entry.id.rawValue,
|
||||
container: .trashed,
|
||||
title: entry.title.value
|
||||
title: entry.title
|
||||
))
|
||||
}
|
||||
|
||||
@@ -559,26 +624,26 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a folder in `.trash/` has the *shape* of a lane — at least one identity-shaped child
|
||||
/// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
|
||||
/// their own `index.md` → lane … else card").
|
||||
/// A folder's **identity-shaped children holding their own `index.md`** — the cards a trash
|
||||
/// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion:
|
||||
/// "UUID-shaped children with their own `index.md` → lane … else card"; 03-board-ui.md § Trash:
|
||||
/// the row's held-card count).
|
||||
///
|
||||
/// Only reached when `kind` did not answer (`IntegrityRules.trashKind`'s `@autoclosure`), and
|
||||
/// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside
|
||||
/// it would load. A trashed lane's cards are never enumerated as levels — the walk stops at a
|
||||
/// trash entry exactly as it stops at a card under a lane.
|
||||
private static func looksLikeALaneFolder(_ folder: URL) -> Bool {
|
||||
/// Deliberately not a parse: this asks what the folder *holds*, not whether anything inside it
|
||||
/// would load. A trashed lane's cards are never enumerated as levels — the walk stops at a trash
|
||||
/// entry exactly as it stops at a card under a lane — so the count is a fact about the freight
|
||||
/// and the entry stays opaque.
|
||||
private static func identityShapedChildren(of folder: URL) -> [URL] {
|
||||
let children = (try? directoryCandidates(in: folder)) ?? []
|
||||
return children.contains { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
||||
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
||||
}
|
||||
|
||||
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
||||
/// One card folder read into a `Card` — the lane walk's card parse.
|
||||
///
|
||||
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
||||
/// function is what makes that literally true rather than a claim two code paths have to keep
|
||||
/// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the
|
||||
/// same verbatim document. Its callers keep what genuinely differs by container — the
|
||||
/// lane-keyed loose-file and legacy-tombstone channels — outside it.
|
||||
/// The trash's own walk reads its entries inline instead, because the container is flat and its
|
||||
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict
|
||||
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
|
||||
/// function that has already decided what it is reading.
|
||||
///
|
||||
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
||||
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
|
||||
@@ -605,7 +670,7 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// The candidate card folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
||||
/// The candidate entry folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
||||
///
|
||||
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
|
||||
/// boards never have one, and a board without a trash is a board with an empty trash.
|
||||
@@ -620,12 +685,10 @@ public enum BoardLoader: Sendable {
|
||||
/// the "window measured in one reload, not a standing state" the ruling accepts.
|
||||
///
|
||||
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
||||
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
||||
/// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a
|
||||
/// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are
|
||||
/// never enumerated, because the walk stops at a card exactly as it does under a lane; the
|
||||
/// outer folder either parses as a card (it has an `index.md`) or is skipped as
|
||||
/// `.missingIndex`, and either way nothing below it renders.
|
||||
/// folder-name order — so the trash gets the same stray tolerance every other container gets.
|
||||
/// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry
|
||||
/// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque
|
||||
/// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked.
|
||||
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
||||
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
||||
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
||||
@@ -945,10 +1008,12 @@ public struct LoadResult: Sendable {
|
||||
/// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
|
||||
/// `kind` value trusted outright, falling through to shape only when it does not answer.
|
||||
///
|
||||
/// A *reading*, not a rendering: `BoardModel.trash` parses every entry through the one card
|
||||
/// parse (a trashed card is "an ordinary card in a special place"), and the container is flat,
|
||||
/// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash
|
||||
/// surface consumes it. Keyed by identity, so it survives the display sort.
|
||||
/// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries
|
||||
/// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out
|
||||
/// of the load as well because it is the *verdict* rather than its consequence — a suite pins
|
||||
/// the discriminator directly, and a consumer asking "what did this entry read as" gets the
|
||||
/// answer without inferring it from which array the entry landed in. Keyed by identity, so it
|
||||
/// survives the display sort.
|
||||
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
|
||||
/// The cards this walk found holding loose files — a **view over `defects`**, under the name it
|
||||
@@ -959,10 +1024,12 @@ public struct LoadResult: Sendable {
|
||||
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// The legacy `deleted:` keys this walk found — the retired tombstone model's migration input,
|
||||
/// as a **view over `defects`** (01-storage-format.md § Deletion).
|
||||
/// The legacy `deleted:` keys this walk found on **cards** — the retired tombstone model's
|
||||
/// surviving migration input, as a **view over `defects`** (01-storage-format.md § Deletion).
|
||||
///
|
||||
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
||||
/// A lane's key is not here and never will be: it is inert, tolerated, and reported as a
|
||||
/// `LoadWarning.laneLevelDeletedIgnored` (lane clause re-ruled 2026-07-29). Order is the walk's,
|
||||
/// lane by lane.
|
||||
public var legacyTombstones: [LegacyTombstone] {
|
||||
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
||||
}
|
||||
@@ -1018,6 +1085,16 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
||||
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
|
||||
case boardLevelDeletedIgnored
|
||||
|
||||
/// A **lane** carrying a legacy `deleted:` key (01-storage-format.md § Deletion, lane clause
|
||||
/// re-ruled 2026-07-29): the lane loads live and the key is ignored — "no migration machinery,
|
||||
/// no key-strip write, no notice", preserved verbatim like any unhandled key.
|
||||
///
|
||||
/// Its home is here rather than in the defect stream because that is exactly the tolerate tier's
|
||||
/// verdict on it: this was ignored, it is staying exactly where it is, there is nothing to do.
|
||||
/// A *card*'s key is still work and still a `LegacyTombstone` defect — the two halves of the old
|
||||
/// migration parted company with the ruling. `path` is relative to the board root.
|
||||
case laneLevelDeletedIgnored(path: String)
|
||||
|
||||
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity —
|
||||
/// one item typed two ways (01-storage-format.md § Fractal layout ▸ Rules): a spelling some *live*
|
||||
/// occurrence carries wins over one only trash ghosts carry (the container preference, stated
|
||||
@@ -1039,6 +1116,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
|
||||
"\(path): folder name is not UUID-shaped, ignored as a stray"
|
||||
case .boardLevelDeletedIgnored:
|
||||
"index.md: board-level 'deleted' key is meaningless, ignored"
|
||||
case let .laneLevelDeletedIgnored(path):
|
||||
"\(path): lane-level 'deleted' key is inert, ignored — the lane loads live"
|
||||
case let .caseTwinIgnored(path, winner):
|
||||
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user