Skip to content

Commit d2fd418

Browse files
p-linnaneclaude
andauthored
fix: review polish (build-number ordering, chip fallback, shared CLI helpers) (#492)
* fix(kit): ordering and robustness polish - Order build numbers numerically by (cycle, train, build, suffix) so a same-version re-release sorts correctly (24B91 before 24B2091) instead of lexicographically; used by Release's Comparable. Updates the existing ordering test, which had encoded the lexicographic behavior. - resolvedChipFamilies falls back to the arch suffix (e.g. ARM64_T8132 → M4) when the chip label isn't a known display name like "Multiple". - DeviceRegistry uses uniquingKeysWith instead of uniqueKeysWithValues so a duplicate model id in the hand-maintained list can't trap at launch. - ComponentExtractor's integer-decode strategy picks the numerically highest match instead of the lexicographically last one. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Patrick Linnane <patrick@linnane.io> * fix(cli): numeric build ordering in the index; hoist shared print helpers - The releases.json index sort tie-broke on build number lexicographically; use BuildNumber.less so re-release variants order numerically, matching Release's Comparable. - Hoist the identical printStatus/printInline/printError helpers that were duplicated across ScanCommand/ValidateCommand/CleanupCommand into Utilities.swift. printError now consistently prefixes "Error: " (so the show/compare "not found" diagnostics gain it too). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Patrick Linnane <patrick@linnane.io> --------- Signed-off-by: Patrick Linnane <patrick@linnane.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a40ea5b commit d2fd418

10 files changed

Lines changed: 126 additions & 47 deletions

File tree

Sources/macOSdbKit/Models/DeviceRegistry.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ public enum DeviceRegistry {
176176
marketingName: "Apple Virtual Machine")
177177
]
178178

179-
return Dictionary(uniqueKeysWithValues: entries.map { ($0.model, $0) })
179+
// Keep the first entry if a duplicate model id ever slips into the
180+
// hand-maintained list, rather than trapping as uniqueKeysWithValues would.
181+
return Dictionary(entries.map { ($0.model, $0) }, uniquingKeysWith: { first, _ in first })
180182
}()
181183
}

Sources/macOSdbKit/Models/KernelInfo.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ public struct KernelInfo: Codable, Identifiable, Hashable, Sendable {
5757
ChipFamily.from(chipName: chip)
5858
}
5959

60-
/// Uses per-device resolution when available, falls back to the kernel-level chip.
60+
/// Uses per-device resolution when available, falls back to the kernel-level
61+
/// chip, then to the arch suffix (so an unmapped label like "Multiple" on older
62+
/// data still resolves via e.g. "ARM64_T8132" → M4).
6163
public var resolvedChipFamilies: [ChipFamily] {
6264
if let deviceChips, !deviceChips.isEmpty {
6365
var seen = Set<ChipFamily>()
@@ -67,11 +69,15 @@ public struct KernelInfo: Codable, Identifiable, Hashable, Sendable {
6769
result.append(family)
6870
}
6971
}
70-
return result
72+
if !result.isEmpty { return result }
7173
}
7274
if let family = chipFamily {
7375
return [family]
7476
}
77+
if let suffix = arch.split(separator: "_").last.map(String.init),
78+
suffix != arch, let family = ChipFamily.from(archSuffix: suffix) {
79+
return [family]
80+
}
7581
return []
7682
}
7783
}

Sources/macOSdbKit/Models/Release.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ extension Release: Comparable {
299299
if lhs.prereleaseRank != rhs.prereleaseRank {
300300
return lhs.prereleaseRank < rhs.prereleaseRank
301301
}
302-
return lhs.buildNumber < rhs.buildNumber
302+
return BuildNumber.less(lhs.buildNumber, rhs.buildNumber)
303303
}
304304

305305
private var prereleaseRank: (Int, Int) {
@@ -309,13 +309,39 @@ extension Release: Comparable {
309309
}
310310
}
311311

312-
enum BuildNumber {
312+
public enum BuildNumber {
313313
/// Apple beta build numbers end with a lowercase letter (e.g. `25E5207k`),
314314
/// while release builds end with a digit (e.g. `24G90`).
315315
static func isBeta(_ buildNumber: String) -> Bool {
316316
guard let last = buildNumber.last else { return false }
317317
return last.isLetter && last.isLowercase
318318
}
319+
320+
/// Orders Apple build numbers by (cycle, train, build, suffix) so re-release
321+
/// variants compare numerically (24B83 < 24B2083) rather than lexicographically
322+
/// (where "24B2083" < "24B83" because '2' < '8').
323+
public static func less(_ lhs: String, _ rhs: String) -> Bool {
324+
let lhsParts = parse(lhs), rhsParts = parse(rhs)
325+
if lhsParts.cycle != rhsParts.cycle { return lhsParts.cycle < rhsParts.cycle }
326+
if lhsParts.train != rhsParts.train { return lhsParts.train < rhsParts.train }
327+
if lhsParts.build != rhsParts.build { return lhsParts.build < rhsParts.build }
328+
return lhsParts.suffix < rhsParts.suffix
329+
}
330+
331+
/// Splits e.g. "24B2083" → (24, "B", 2083, "") and "24A5331b" → (24, "A", 5331, "b").
332+
private static func parse(_ build: String) -> (cycle: Int, train: String, build: Int, suffix: String) {
333+
let chars = Array(build)
334+
var idx = 0
335+
func take(_ predicate: (Character) -> Bool) -> String {
336+
let start = idx
337+
while idx < chars.count, predicate(chars[idx]) { idx += 1 }
338+
return String(chars[start..<idx])
339+
}
340+
let cycle = Int(take { $0.isNumber }) ?? 0
341+
let train = take { $0.isLetter }
342+
let build = Int(take { $0.isNumber }) ?? 0
343+
return (cycle, train, build, String(chars[idx...]))
344+
}
319345
}
320346

321347
enum MacOSRelease {

Sources/macOSdbKit/Scanner/ComponentExtractor.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,10 @@ enum ComponentExtractor {
4949
) -> Component? {
5050
let minLength = definition.minLength ?? BinaryStringScanner.defaultMinLength
5151
let matches = BinaryStringScanner.findAll(in: data, matching: definition.pattern, minLength: minLength)
52-
let uniqueMatches = Array(Set(matches)).sorted()
5352

54-
// Take the last (highest) match — typically the actual version
55-
guard let intString = uniqueMatches.last,
56-
let intValue = Int(intString) else {
53+
// Take the numerically highest match. A lexicographic sort would rank
54+
// different-width integers wrong — e.g. "9" sorts above "21209".
55+
guard let intValue = Set(matches).compactMap({ Int($0) }).max() else {
5756
logger.debug("\(definition.name): no integer version found")
5857
return nil
5958
}

Sources/macosdb/CleanupCommand.swift

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,4 @@ struct CleanupCommand: AsyncParsableCommand {
188188
}
189189
}
190190

191-
// MARK: - Helpers
192-
193-
private func printStatus(_ message: String) {
194-
FileHandle.standardError.write(Data((message + "\n").utf8))
195-
}
196-
197-
private func printInline(_ message: String) {
198-
let line = message.isEmpty ? "\r\u{1B}[K" : "\r\(message)"
199-
FileHandle.standardError.write(Data(line.utf8))
200-
}
201191
}

Sources/macosdb/ScanCommand.swift

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ struct ScanCommand: AsyncParsableCommand {
114114
await configureProgress(scanner)
115115
if verbose {
116116
await scanner.setVerbose { message in
117-
self.printStatus("[verbose] \(message)")
117+
printStatus("[verbose] \(message)")
118118
}
119119
}
120120

@@ -146,7 +146,7 @@ struct ScanCommand: AsyncParsableCommand {
146146
await configureProgress(scanner)
147147
if verbose {
148148
await scanner.setVerbose { message in
149-
self.printStatus("[verbose] \(message)")
149+
printStatus("[verbose] \(message)")
150150
}
151151
}
152152

@@ -172,7 +172,7 @@ struct ScanCommand: AsyncParsableCommand {
172172
await configureProgress(scanner)
173173
if verbose {
174174
await scanner.setVerbose { message in
175-
self.printStatus("[verbose] \(message)")
175+
printStatus("[verbose] \(message)")
176176
}
177177
}
178178

@@ -320,7 +320,7 @@ struct ScanCommand: AsyncParsableCommand {
320320
let lhsRank = lhs.isBeta ? 0 : lhs.isRC ? 1 : 2
321321
let rhsRank = rhs.isBeta ? 0 : rhs.isRC ? 1 : 2
322322
if lhsRank != rhsRank { return lhsRank > rhsRank }
323-
return lhs.buildNumber > rhs.buildNumber
323+
return BuildNumber.less(rhs.buildNumber, lhs.buildNumber)
324324
}
325325

326326
let encoder = JSONEncoder()
@@ -348,13 +348,6 @@ struct ScanCommand: AsyncParsableCommand {
348348
}
349349
}
350350

351-
private func printStatus(_ message: String) {
352-
FileHandle.standardError.write(Data((message + "\n").utf8))
353-
}
354-
355-
private func printError(_ message: String) {
356-
FileHandle.standardError.write(Data(("Error: " + message + "\n").utf8))
357-
}
358351
}
359352

360353
extension IPSWScanner {

Sources/macosdb/Utilities.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ nonisolated func makeDataProvider(dataURL: String?) -> DataProvider {
88
return DataProvider()
99
}
1010

11-
/// Writes a diagnostic line to standard error. CLI errors belong on stderr so
11+
/// Writes an "Error: …" line to standard error. CLI errors belong on stderr so
1212
/// they don't contaminate the stdout stream consumers parse (e.g. --json output).
1313
nonisolated func printError(_ message: String) {
14+
FileHandle.standardError.write(Data(("Error: " + message + "\n").utf8))
15+
}
16+
17+
/// Writes a status/progress line to standard error.
18+
nonisolated func printStatus(_ message: String) {
1419
FileHandle.standardError.write(Data((message + "\n").utf8))
1520
}
1621

22+
/// Writes an in-place progress line (carriage return, no newline) to standard error.
23+
nonisolated func printInline(_ message: String) {
24+
let line = message.isEmpty ? "\r\u{1B}[K" : "\r\(message)"
25+
FileHandle.standardError.write(Data(line.utf8))
26+
}
27+
1728
nonisolated func writeJSON<T: Encodable>(_ value: T) throws {
1829
let encoder = JSONEncoder()
1930
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]

Sources/macosdb/ValidateCommand.swift

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,4 @@ struct ValidateCommand: AsyncParsableCommand {
183183
return urls
184184
}
185185

186-
private func printStatus(_ message: String) {
187-
FileHandle.standardError.write(Data((message + "\n").utf8))
188-
}
189-
190-
private func printInline(_ message: String) {
191-
let line = message.isEmpty ? "\r\u{1B}[K" : "\r\(message)"
192-
FileHandle.standardError.write(Data(line.utf8))
193-
}
194186
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Testing
2+
3+
@testable import macOSdbKit
4+
5+
@Suite("Build number ordering")
6+
struct BuildNumberOrderingTests {
7+
8+
@Test("Re-release variant sorts after the original (numeric, not lexicographic)")
9+
func reReleaseOrdering() {
10+
// Lexicographically "24B2083" < "24B83" (because '2' < '8'); numerically 83 < 2083.
11+
#expect(BuildNumber.less("24B83", "24B2083"))
12+
#expect(!BuildNumber.less("24B2083", "24B83"))
13+
}
14+
15+
@Test("Orders by cycle, then train, then build, then suffix")
16+
func componentOrdering() {
17+
#expect(BuildNumber.less("23G93", "24A100")) // cycle: 23 < 24
18+
#expect(BuildNumber.less("24A300", "24B83")) // train: A < B
19+
#expect(BuildNumber.less("25A8364", "25A8365")) // build: 8364 < 8365
20+
#expect(BuildNumber.less("24A5331", "24A5331b")) // suffix: "" < "b"
21+
}
22+
23+
@Test("Equal build numbers are not less than each other")
24+
func equalBuilds() {
25+
#expect(!BuildNumber.less("24G90", "24G90"))
26+
}
27+
}
28+
29+
@Suite("Chip family resolution")
30+
struct ChipFamilyResolutionTests {
31+
32+
@Test("Falls back to the arch suffix when the chip label is unmapped")
33+
func archSuffixFallback() {
34+
// Older data: chip label "Multiple" maps to no display name and there are no
35+
// deviceChips, so resolution must fall back to the arch suffix.
36+
let kernel = KernelInfo(
37+
file: "kernelcache.release.mac15",
38+
darwinVersion: "24.0.0",
39+
arch: "ARM64_T8132",
40+
chip: "Multiple",
41+
devices: ["Mac16,1", "Mac16,2"]
42+
)
43+
#expect(kernel.resolvedChipFamilies == [.m4])
44+
}
45+
46+
@Test("Per-device chips take priority over the arch fallback")
47+
func deviceChipsPreferred() {
48+
let kernel = KernelInfo(
49+
file: "kernelcache.release.mac15",
50+
darwinVersion: "24.0.0",
51+
arch: "ARM64_T6041",
52+
chip: "Multiple",
53+
devices: ["Mac16,5"],
54+
deviceChips: [DeviceChip(device: "Mac16,5", chip: "M4 Max")]
55+
)
56+
#expect(kernel.resolvedChipFamilies == [.m4Max])
57+
}
58+
}

Tests/macOSdbKitTests/ModelTests.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,17 @@ struct ModelTests { // swiftlint:disable:this type_body_length
9393
#expect(older < newer)
9494
}
9595

96-
@Test("Release sorting — same version different builds is deterministic")
96+
@Test("Release sorting — same version orders builds numerically, not lexicographically")
9797
func releaseSortingSameVersion() {
98-
let buildA = Release(osVersion: "15.1.1", buildNumber: "24B2091", releaseName: "Sequoia")
99-
let buildB = Release(osVersion: "15.1.1", buildNumber: "24B91", releaseName: "Sequoia")
100-
#expect(buildA < buildB)
101-
#expect(!(buildB < buildA))
102-
// Verify sorted order is stable
103-
let sorted = [buildB, buildA].sorted()
104-
#expect(sorted.map(\.buildNumber) == ["24B2091", "24B91"])
98+
// 24B91 (original) predates 24B2091 (the later device-specific re-release).
99+
// Numeric ordering must place 91 before 2091; a lexicographic compare would
100+
// wrongly rank "24B2091" first because '2' < '8'.
101+
let original = Release(osVersion: "15.1.1", buildNumber: "24B91", releaseName: "Sequoia")
102+
let reRelease = Release(osVersion: "15.1.1", buildNumber: "24B2091", releaseName: "Sequoia")
103+
#expect(original < reRelease)
104+
#expect(!(reRelease < original))
105+
let sorted = [reRelease, original].sorted()
106+
#expect(sorted.map(\.buildNumber) == ["24B91", "24B2091"])
105107
}
106108

107109
@Test("Release component lookup by name")

0 commit comments

Comments
 (0)