From 22c9584afdfa7ddee79fe6ebff4d6f9ce09e899a Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 20:00:24 -0700 Subject: [PATCH 1/4] Release 1.3.0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7323369..99c854a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [1.3.0] - 2026-09-14 + +### Changed + +- Lowered the platform floor to macOS 13, iOS 16, watchOS 9, tvOS 16, and + visionOS 1, down from 26 on every platform. The manifest had required + releases far newer than anything the package uses — `TimeZone.gmt` is the + newest API it touches — so apps that have not moved to the 26 releases can + now adopt the library unchanged. +- Raised the minimum version of two package dependencies: swift-argument-parser + 1.8.2 and swift-docc-plugin 1.5.0. + ### Fixed - Parsing a truncated record no longer traps. `slice(_:)` clamped only its upper From 5c2884a0577ca37db8c2843e9ca0ea571378e866 Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 13:05:49 -0700 Subject: [PATCH 2/4] Modernize byte parsing and line readers for Swift 6.4 Decode CIFP fields through UTF8Span over the slice's own storage instead of copying each field into an Array first. `toString()`/`toRawString()` run on roughly 101 call sites per record across hundreds of thousands of records, so the per-field allocation dominated the string side of parsing; the new form is about 2.3x faster on a field-decode microbenchmark and produces byte-identical output (verified exhaustively over all one- and two-byte sequences plus 400k random and ASCII-biased fields, and end-to-end against a 60k-record synthetic corpus under SWIFT_DETERMINISTIC_HASHING). Give both async line reader iterators typed throws. The file-backed reader now throws `CIFPError.streamError`, wrapping the underlying `FileHandle` failure, which makes `CIFP(url:)` match its documented contract of throwing `CIFPError`. The generic byte-sequence reader propagates its source's `Failure` type, so a non-throwing source now yields a non-throwing sequence. Add Swift 6.4 CI legs on macOS and Linux, and drop the matrix policy comment that flagged the bump as pending. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- .github/workflows/ci.yml | 14 +++------- Sources/SwiftCIFP/Parser/ByteParsing.swift | 22 +++++++++++++--- Sources/SwiftCIFP/Parser/CIFPLineReader.swift | 26 +++++++++++++++---- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 178e677..051efaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,3 @@ -# CI Matrix Policy -# -# Category A (tools-version 6.2 + macOS 26 min): macos-26 + Swift 6.2 -# Category B (tools-version 6.2 + older macOS): macos-15 + 6.2, macos-26 + 6.2 -# Category C (tools-version 6.0): macos-15 + 6.0, macos-15 + 6.2, macos-26 + 6.2 -# Linux: ubuntu + Swift 6.3 -# -# When Swift 6.3 ships: bump 6.0→6.1 and 6.2→6.3 in Category C -# When bumping tools-version to 6.2: drop 6.0/6.1, move to Category A or B - name: CI on: @@ -29,8 +19,12 @@ jobs: include: - os: macos-26 swift: "6.2" + - os: macos-26 + swift: "6.4" - os: ubuntu-latest swift: "6.3" + - os: ubuntu-latest + swift: "6.4" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 diff --git a/Sources/SwiftCIFP/Parser/ByteParsing.swift b/Sources/SwiftCIFP/Parser/ByteParsing.swift index c02342c..bb95d2a 100644 --- a/Sources/SwiftCIFP/Parser/ByteParsing.swift +++ b/Sources/SwiftCIFP/Parser/ByteParsing.swift @@ -111,14 +111,17 @@ extension RandomAccessCollection where Element == UInt8, Index == Int { /// Convert to trimmed String (only when actually needed). @inlinable func toString() -> String { - guard let string = String(bytes: Array(self), encoding: .utf8) else { return "" } - return string.trimmingCharacters(in: .whitespaces) + toRawString().trimmingCharacters(in: .whitespaces) } /// Convert to raw String without trimming (for exact matching like section codes). + /// + /// Decodes in place over the collection's own storage, so no intermediate array is + /// allocated per field. Bytes that are not valid UTF-8 yield an empty string. @inlinable func toRawString() -> String { - String(bytes: Array(self), encoding: .utf8) ?? "" + withContiguousStorageIfAvailable(String.init(validatingUTF8Bytes:)) + ?? ContiguousArray(self).withUnsafeBufferPointer(String.init(validatingUTF8Bytes:)) } /// Check if all bytes are whitespace. @@ -127,3 +130,16 @@ extension RandomAccessCollection where Element == UInt8, Index == Int { allSatisfy { $0 == ASCII.space } } } + +extension String { + /// Create a string from contiguous UTF-8 bytes without copying them into an + /// intermediate array, yielding an empty string when the bytes are not valid UTF-8. + @inlinable + init(validatingUTF8Bytes bytes: UnsafeBufferPointer) { + guard let utf8 = try? UTF8Span(validating: bytes.span) else { + self = "" + return + } + self.init(copying: utf8) + } +} diff --git a/Sources/SwiftCIFP/Parser/CIFPLineReader.swift b/Sources/SwiftCIFP/Parser/CIFPLineReader.swift index 1205f88..19c78c3 100644 --- a/Sources/SwiftCIFP/Parser/CIFPLineReader.swift +++ b/Sources/SwiftCIFP/Parser/CIFPLineReader.swift @@ -99,12 +99,12 @@ struct AsyncCIFPLineReader: AsyncSequence, Sendable { self.lineBuffer.reserveCapacity(lineBufferCapacity) } - mutating func next() throws -> [UInt8]? { + mutating func next() throws(CIFPError) -> [UInt8]? { guard !isEOF else { return nil } // Lazily open file handle on first call if handle == nil { - handle = try FileHandle(forReadingFrom: url) + handle = try openFile() } guard let handle else { preconditionFailure("handle was nil") } @@ -113,7 +113,7 @@ struct AsyncCIFPLineReader: AsyncSequence, Sendable { while true { // Refill buffer if exhausted if bufferPos >= buffer.count { - guard let chunk = try handle.read(upToCount: bufferSize), + guard let chunk = try readChunk(from: handle), !chunk.isEmpty else { isEOF = true @@ -138,6 +138,22 @@ struct AsyncCIFPLineReader: AsyncSequence, Sendable { lineBuffer.append(byte) } } + + private func openFile() throws(CIFPError) -> FileHandle { + do { + return try FileHandle(forReadingFrom: url) + } catch { + throw .streamError(error) + } + } + + private func readChunk(from handle: FileHandle) throws(CIFPError) -> Data? { + do { + return try handle.read(upToCount: bufferSize) + } catch { + throw .streamError(error) + } + } } } @@ -172,10 +188,10 @@ where Source.Element == UInt8, Source: Sendable { } @concurrent - mutating func next() async throws -> [UInt8]? { + mutating func next() async throws(Source.Failure) -> [UInt8]? { lineBuffer.removeAll(keepingCapacity: true) - while let byte = try await iterator.next() { + while let byte = try await iterator.next(isolation: nil) { if byte == ASCII.LF { // Strip trailing CR if present (handles CRLF) if lineBuffer.last == ASCII.CR { From 3e02d4201382cd20e448ba08f050b98bed6d617d Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 16:06:32 -0700 Subject: [PATCH 3/4] Keep the macOS 26 floor for the UTF8Span work `main` lowered this package's floor to what its code there requires. The byte-parsing work on this branch uses `UTF8Span`, which is macOS 26, so the branch declares the floor its own code needs. Merging this therefore raises the floor. That is the trade the branch asks for and it should be decided on the merge, not worked around in the source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 1528478..2d0bde8 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let upcomingFeatures: [SwiftSetting] = [ let package = Package( name: "SwiftCIFP", defaultLocalization: "en", - platforms: [.macOS(.v13), .iOS(.v16), .watchOS(.v9), .tvOS(.v16), .visionOS(.v1)], + platforms: [.macOS(.v26), .iOS(.v26), .watchOS(.v26), .tvOS(.v26), .visionOS(.v26)], products: [ .library( name: "SwiftCIFP", From 42522b07a40099135b76c8f42d8cbe9d9cfdf59a Mon Sep 17 00:00:00 2001 From: Tim Morgan Date: Mon, 14 Sep 2026 18:00:10 -0700 Subject: [PATCH 4/4] Adopt strict memory safety Enable `.strictMemorySafety()` (SE-0458) alongside the existing upcoming feature flags and audit every unsafe construct it surfaces, marking each with the `unsafe` expression marker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EbZbx5D2gGeXT8UxuiKEdq --- Package.swift | 3 ++- Sources/SwiftCIFP/Cycle.swift | 2 +- Sources/SwiftCIFP/Parser/ByteParsing.swift | 6 +++--- Sources/SwiftCIFP/Parser/CIFPLineReader.swift | 6 +++--- Sources/SwiftCIFP/Types/Coordinate.swift | 8 +++++++- Sources/SwiftCIFP/Types/MagneticVariation.swift | 2 +- Sources/SwiftCIFP_E2E/OutputFormatter.swift | 16 +++++++++------- Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift | 4 ++-- 8 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Package.swift b/Package.swift index 2d0bde8..aaa9b73 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,8 @@ let upcomingFeatures: [SwiftSetting] = [ .enableUpcomingFeature("ImmutableWeakCaptures"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") + .enableUpcomingFeature("InternalImportsByDefault"), + .strictMemorySafety() ] let package = Package( diff --git a/Sources/SwiftCIFP/Cycle.swift b/Sources/SwiftCIFP/Cycle.swift index a3a5644..8d06719 100644 --- a/Sources/SwiftCIFP/Cycle.swift +++ b/Sources/SwiftCIFP/Cycle.swift @@ -141,7 +141,7 @@ extension Cycle { extension Cycle { /// The YYMM string representation (used internally for identification). var yymm: String { - String(format: "%02d%02d", year % 100, cycleNumber) + unsafe String(format: "%02d%02d", year % 100, cycleNumber) } } diff --git a/Sources/SwiftCIFP/Parser/ByteParsing.swift b/Sources/SwiftCIFP/Parser/ByteParsing.swift index bb95d2a..60b2cce 100644 --- a/Sources/SwiftCIFP/Parser/ByteParsing.swift +++ b/Sources/SwiftCIFP/Parser/ByteParsing.swift @@ -120,8 +120,8 @@ extension RandomAccessCollection where Element == UInt8, Index == Int { /// allocated per field. Bytes that are not valid UTF-8 yield an empty string. @inlinable func toRawString() -> String { - withContiguousStorageIfAvailable(String.init(validatingUTF8Bytes:)) - ?? ContiguousArray(self).withUnsafeBufferPointer(String.init(validatingUTF8Bytes:)) + unsafe (withContiguousStorageIfAvailable(String.init(validatingUTF8Bytes:)) + ?? ContiguousArray(self).withUnsafeBufferPointer(String.init(validatingUTF8Bytes:))) } /// Check if all bytes are whitespace. @@ -136,7 +136,7 @@ extension String { /// intermediate array, yielding an empty string when the bytes are not valid UTF-8. @inlinable init(validatingUTF8Bytes bytes: UnsafeBufferPointer) { - guard let utf8 = try? UTF8Span(validating: bytes.span) else { + guard let utf8 = try? UTF8Span(validating: unsafe bytes.span) else { self = "" return } diff --git a/Sources/SwiftCIFP/Parser/CIFPLineReader.swift b/Sources/SwiftCIFP/Parser/CIFPLineReader.swift index 19c78c3..5e9642b 100644 --- a/Sources/SwiftCIFP/Parser/CIFPLineReader.swift +++ b/Sources/SwiftCIFP/Parser/CIFPLineReader.swift @@ -26,10 +26,10 @@ struct CIFPLineReader: Sequence, IteratorProtocol, Sendable { lineBuffer.removeAll(keepingCapacity: true) // Scan until LF or end of data - data.withUnsafeBytes { buffer in - let bytes = buffer.bindMemory(to: UInt8.self) + unsafe data.withUnsafeBytes { buffer in + let bytes = unsafe buffer.bindMemory(to: UInt8.self) while position < bytes.count { - let byte = bytes[position] + let byte = unsafe bytes[position] position += 1 if byte == ASCII.LF { return } lineBuffer.append(byte) diff --git a/Sources/SwiftCIFP/Types/Coordinate.swift b/Sources/SwiftCIFP/Types/Coordinate.swift index a8ef324..b30c57e 100644 --- a/Sources/SwiftCIFP/Types/Coordinate.swift +++ b/Sources/SwiftCIFP/Types/Coordinate.swift @@ -58,6 +58,12 @@ extension Coordinate: CustomStringConvertible { public var description: String { let latDir = latitudeDeg >= 0 ? "N" : "S" let lonDir = longitudeDeg >= 0 ? "E" : "W" - return String(format: "%.6f°%@, %.6f°%@", abs(latitudeDeg), latDir, abs(longitudeDeg), lonDir) + return unsafe String( + format: "%.6f°%@, %.6f°%@", + abs(latitudeDeg), + latDir, + abs(longitudeDeg), + lonDir + ) } } diff --git a/Sources/SwiftCIFP/Types/MagneticVariation.swift b/Sources/SwiftCIFP/Types/MagneticVariation.swift index 9e8ba60..27b35c0 100644 --- a/Sources/SwiftCIFP/Types/MagneticVariation.swift +++ b/Sources/SwiftCIFP/Types/MagneticVariation.swift @@ -59,6 +59,6 @@ extension MagneticVariation { extension MagneticVariation: CustomStringConvertible { public var description: String { - String(format: "%.1f°%@", degrees, String(direction.rawValue)) + unsafe String(format: "%.1f°%@", degrees, String(direction.rawValue)) } } diff --git a/Sources/SwiftCIFP_E2E/OutputFormatter.swift b/Sources/SwiftCIFP_E2E/OutputFormatter.swift index dd81808..d2d1767 100644 --- a/Sources/SwiftCIFP_E2E/OutputFormatter.swift +++ b/Sources/SwiftCIFP_E2E/OutputFormatter.swift @@ -18,9 +18,10 @@ protocol OutputFormatter { extension OutputStream { func write(_ string: String) { guard let data = string.data(using: .utf8) else { return } - data.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - write(pointer, maxLength: buffer.count) + unsafe data.withUnsafeBytes { buffer in + guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { return } + unsafe write(pointer, maxLength: buffer.count) } } @@ -39,7 +40,7 @@ struct SummaryOutputFormatter: OutputFormatter { stream.writeLine() stream.writeLine("=== CIFP Summary ===") stream.writeLine("Cycle: \(cifp.cycle)") - stream.writeLine("Parse time: \(String(format: "%.2f", elapsed)) seconds") + stream.writeLine("Parse time: \(unsafe String(format: "%.2f", elapsed)) seconds") stream.writeLine("Errors: \(errorCount)") stream.writeLine() @@ -99,9 +100,10 @@ struct JSONOutputFormatter: OutputFormatter { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let jsonData = try encoder.encode(snapshot) - jsonData.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - stream.write(pointer, maxLength: buffer.count) + unsafe jsonData.withUnsafeBytes { buffer in + guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { return } + unsafe stream.write(pointer, maxLength: buffer.count) } } } diff --git a/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift b/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift index eb0d61a..2feff54 100644 --- a/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift +++ b/Sources/SwiftCIFP_E2E/SwiftCIFP_E2E.swift @@ -47,8 +47,8 @@ struct SwiftCIFP_E2E: AsyncParsableCommand { guard let (year, month, day) = dateComponents(from: effectiveDate) else { throw ValidationError("Failed to extract date components from cycle") } - let filename = String(format: Self.cifpFilenameFormat, year % 100, month, day) - guard let url = URL(string: String(format: Self.cifpURLFormat, filename)) else { + let filename = unsafe String(format: Self.cifpFilenameFormat, year % 100, month, day) + guard let url = URL(string: unsafe String(format: Self.cifpURLFormat, filename)) else { throw ValidationError("Failed to construct CIFP URL") } return url