From 2f348d72c751f70f6f6b10b0e896c8f05bc9e5a6 Mon Sep 17 00:00:00 2001 From: giginet Date: Fri, 27 Feb 2026 15:51:53 +0900 Subject: [PATCH 1/5] test: add Xcode 26.4 BuildOperationMetrics format test Add test case for the new BuildOperationMetrics JSON format introduced in Xcode 26.4 (`counters`/`taskCounters`), verifying both new format parsing and that old format fields are nil. Co-Authored-By: Claude Opus 4.6 Signed-off-by: giginet --- .../ActivityParserTests.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/Tests/XCLogParserTests/ActivityParserTests.swift b/Tests/XCLogParserTests/ActivityParserTests.swift index 443d93a..c845c63 100644 --- a/Tests/XCLogParserTests/ActivityParserTests.swift +++ b/Tests/XCLogParserTests/ActivityParserTests.swift @@ -178,6 +178,42 @@ class ActivityParserTests: XCTestCase { return startTokens + logMessageTokens + endTokens }() + // Xcode 26.4 format: BuildOperationMetrics JSON format changed + lazy var IDEActivityLogSectionTokensXcode264: [Token] = { + let startTokens = [Token.int(2), + Token.string("com.apple.dt.IDE.BuildLogSection"), + Token.string("Prepare build"), + Token.string("Prepare build"), + Token.double(575479851.278759), + Token.double(575479851.778325), + Token.null, + Token.string("note: Using legacy build system"), + Token.list(1), + Token.className("IDEActivityLogMessage"), + Token.classNameRef("IDEActivityLogMessage"), + ] + let logMessageTokens = IDEActivityLogMessageTokens + let endTokens = [Token.int(1), + Token.int(0), + Token.int(1), + Token.int(42), // unknown integer before subtitle (Xcode 26.2+) + Token.string("subtitle"), + Token.null, + Token.string("commandDetailDesc"), + Token.string("501796C4-6BE4-4F80-9F9D-3269617ECC17"), + Token.string("localizedResultString"), + Token.string("xcbuildSignature"), + Token.list(1), // 1 attachment + Token.classNameRef("IDEFoundation.IDEActivityLogSectionAttachment"), + Token.string("com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics"), + Token.int(1), + Token.int(0), + // swiftlint:disable:next line_length + Token.json(#"{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}}"#), + ] + return startTokens + logMessageTokens + endTokens + }() + let IDEConsoleItemTokens: [Token] = [ Token.className("IDEConsoleItem"), Token.classNameRef("IDEConsoleItem"), @@ -445,6 +481,42 @@ class ActivityParserTests: XCTestCase { XCTAssertEqual(42, logSection.unknown) } + func testParseIDEActivityLogSectionXcode264() throws { + parser.logVersion = 12 + let tokens = IDEActivityLogSectionTokensXcode264 + var iterator = tokens.makeIterator() + let logSection = try parser.parseIDEActivityLogSection(iterator: &iterator) + XCTAssertEqual(2, logSection.sectionType) + XCTAssertEqual("com.apple.dt.IDE.BuildLogSection", logSection.domainType) + XCTAssertEqual("Prepare build", logSection.title) + XCTAssertEqual("Prepare build", logSection.signature) + XCTAssertEqual(575479851.278759, logSection.timeStartedRecording) + XCTAssertEqual(575479851.778325, logSection.timeStoppedRecording) + XCTAssertEqual(0, logSection.subSections.count) + XCTAssertEqual("note: Using legacy build system", logSection.text) + XCTAssertEqual(1, logSection.messages.count) + XCTAssertTrue(logSection.wasCancelled) + XCTAssertFalse(logSection.isQuiet) + XCTAssertTrue(logSection.wasFetchedFromCache) + XCTAssertEqual("subtitle", logSection.subtitle) + XCTAssertEqual("", logSection.location.documentURLString) + XCTAssertEqual(0, logSection.location.timestamp) + XCTAssertEqual("commandDetailDesc", logSection.commandDetailDesc) + XCTAssertEqual("501796C4-6BE4-4F80-9F9D-3269617ECC17", logSection.uniqueIdentifier) + XCTAssertEqual("localizedResultString", logSection.localizedResultString) + XCTAssertEqual("xcbuildSignature", logSection.xcbuildSignature) + XCTAssertEqual(1, logSection.attachments.count) + let metrics = logSection.attachments[0].buildOperationMetrics + XCTAssertNotNil(metrics) + XCTAssertNil(metrics?.clangCacheHits) + XCTAssertNil(metrics?.clangCacheMisses) + XCTAssertNil(metrics?.swiftCacheHits) + XCTAssertNil(metrics?.swiftCacheMisses) + XCTAssertEqual(metrics?.counters, [:]) + XCTAssertEqual(metrics?.taskCounters?["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1) + XCTAssertEqual(42, logSection.unknown) + } + func testParseActivityLog() throws { let activityLog = try parser.parseIDEActiviyLogFromTokens(IDEActivityLogTokens) XCTAssertEqual(10, activityLog.version) From a360ff3187dd81bb07c8009011b71e93f459ee2b Mon Sep 17 00:00:00 2001 From: giginet Date: Fri, 27 Feb 2026 15:56:04 +0900 Subject: [PATCH 2/5] docs: document Xcode 26.4 attachment and BuildOperationMetrics changes Update Xcactivitylog Format doc with SLF version history table, IDEActivityLogSectionAttachment section, and BuildOperationMetrics format change in Xcode 26.4. Update JSON Format doc with Attachments section covering BuildOperationTaskMetrics, BuildOperationMetrics (both old and Xcode 26.4+ formats), and BuildOperationTaskBacktrace. Co-Authored-By: Claude Opus 4.6 Signed-off-by: giginet --- docs/JSON Format.md | 81 ++++++++++++++++++++++++++++++++++++ docs/Xcactivitylog Format.md | 36 +++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/JSON Format.md b/docs/JSON Format.md index cf321ec..4ce936a 100644 --- a/docs/JSON Format.md +++ b/docs/JSON Format.md @@ -76,6 +76,87 @@ Other fields: - `compilationEndTimestamp`: Timestamp in which the actual compilation finished. For a Target this could be before `endTimestamp` because in the new Xcode Build System linking can happen way after compilation. - `compilationDuration` Actual duration in seconds of just the compilation phase. In a Target this could be significant shorter than the `duration`. +## Attachments (Xcode 15.3+) + +When using the `dump` command, each `IDEActivityLogSection` may include an `attachments` array. This was introduced in SLF version 11 (Xcode 15.3). Each attachment has an `identifier`, `majorVersion`, `minorVersion`, and one of the following typed payloads: + +### BuildOperationTaskMetrics + +Per-task execution metrics: + +```json +{ + "identifier": "com.apple.dt.ActivityLogSectionAttachment.TaskMetrics", + "majorVersion": 1, + "minorVersion": 0, + "metrics": { + "utime": 1834, + "stime": 1723, + "maxRSS": 3391488, + "wcStartTime": 793866997013418, + "wcDuration": 141479 + } +} +``` + +### BuildOperationMetrics + +Aggregate build operation metrics. The JSON format differs by Xcode version: + +**Xcode 15.3 - Xcode 16.x:** +```json +{ + "identifier": "com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics", + "majorVersion": 1, + "minorVersion": 0, + "buildOperationMetrics": { + "clangCacheHits": 0, + "clangCacheMisses": 2, + "swiftCacheHits": 0, + "swiftCacheMisses": 8 + } +} +``` + +**Xcode 26.4+:** +```json +{ + "identifier": "com.apple.dt.ActivityLogSectionAttachment.BuildOperationMetrics", + "majorVersion": 1, + "minorVersion": 0, + "buildOperationMetrics": { + "counters": {}, + "taskCounters": { + "SwiftDriver": { + "moduleDependenciesNotValidatedTasks": 1 + } + } + } +} +``` + +All fields in `buildOperationMetrics` are optional to support both formats. + +### BuildOperationTaskBacktrace + +Backtrace frames explaining why a build task was executed: + +```json +{ + "identifier": "com.apple.dt.ActivityLogSectionAttachment.TaskBacktrace", + "majorVersion": 1, + "minorVersion": 0, + "backtrace": [ + { + "description": "'Planning Swift module Foo' had never run", + "category": { "ruleNeverBuilt": {} } + } + ] +} +``` + +## Detail step types + When possible, the `signature` content of `detail` steps is parsed to determine its type. This makes it easier to aggregate the data. Value | Description diff --git a/docs/Xcactivitylog Format.md b/docs/Xcactivitylog Format.md index 2869f64..71de6b0 100644 --- a/docs/Xcactivitylog Format.md +++ b/docs/Xcactivitylog Format.md @@ -136,7 +136,15 @@ You can get these tokens: [type: "string", value: "Prepare build"], ``` -The first integer is the version of the `SLF` format used. In Xcode 10.x and 11 Beta, the version is 10. The values after the version are the actual content of the log. +The first integer is the version of the `SLF` format used. The values after the version are the actual content of the log. + +### SLF version history + +Version | Xcode Version | Changes +--- | --- | --- +10 | Xcode 10.x - 14.x | Initial format +11 | Xcode 15.3+ | Added `IDEFoundation.IDEActivityLogSectionAttachment` list to `IDEActivityLogSection` +12 | Xcode 26.2+ | Added an unknown integer field before `subtitle` in `IDEActivityLogSection` ## Parsing an xcactivitylog @@ -156,3 +164,29 @@ Inside the logs you can find these classes: If you search for them, you will find that they belong to the IDEFoundation.framework. A private framework part of Xcode. You can class dump it to get the headers of those classes. Once you have the headers, you will have the name and type of the properties that belong to the class. Now, you can match them to the tokens you got from the log. Some of them are in the same order than in the headers, but for others it will be about trial and error. In the project you can find those classes with their properties in the order in which they appear in the log in the file [IDEActivityModel.swift](../Sources/XCLogParser/activityparser/IDEActivityModel.swift). + +## IDEActivityLogSectionAttachment (SLF version 11+) + +Starting with SLF version 11 (Xcode 15.3), each `IDEActivityLogSection` includes an array of `IDEFoundation.IDEActivityLogSectionAttachment` entries after the `xcbuildSignature` field. Each attachment has an `identifier` string, a `majorVersion` integer, a `minorVersion` integer, and a JSON payload. The identifier determines the type of the JSON payload: + +Identifier | JSON Type | Description +--- | --- | --- +`...TaskMetrics` | `BuildOperationTaskMetrics` | Per-task timing metrics (`utime`, `stime`, `maxRSS`, `wcStartTime`, `wcDuration`) +`...TaskBacktrace` | `BuildOperationTaskBacktrace` | Backtrace frames explaining why a task was run +`...BuildOperationMetrics` | `BuildOperationMetrics` | Aggregate build operation metrics (see below) + +### BuildOperationMetrics format changes + +In **Xcode 15.3 - Xcode 16.x**, the `BuildOperationMetrics` JSON contains compiler cache statistics: + +```json +{"clangCacheHits":0,"clangCacheMisses":2,"swiftCacheHits":0,"swiftCacheMisses":8} +``` + +Starting with **Xcode 26.4**, the format changed to a dynamic counter-based structure: + +```json +{"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}} +``` + +XCLogParser supports both formats. All fields are optional to maintain backward compatibility. From 15db7a33e85d4df452e3d1b88e354c14d3edf500 Mon Sep 17 00:00:00 2001 From: giginet Date: Thu, 5 Mar 2026 12:51:22 +0900 Subject: [PATCH 3/5] refactor: use enum with associated values for BuildOperationMetrics Replace the struct with optional fields approach with an enum that clearly distinguishes between Xcode 15.3-16.x (v15_3) and Xcode 26.4+ (v26_4) formats using associated values. This replaces the decodeIfPresent approach from #246 with a try-decode-fallback strategy that auto-detects the format. Co-Authored-By: Claude Opus 4.6 Signed-off-by: giginet --- .../activityparser/ActivityParser.swift | 25 ++++++-- .../activityparser/IDEActivityModel.swift | 56 ++++++++++------- .../ActivityParserTests.swift | 60 ++++++++++--------- docs/JSON Format.md | 2 +- docs/Xcactivitylog Format.md | 2 +- 5 files changed, 88 insertions(+), 57 deletions(-) diff --git a/Sources/XCLogParser/activityparser/ActivityParser.swift b/Sources/XCLogParser/activityparser/ActivityParser.swift index 155038e..5e1b5f2 100644 --- a/Sources/XCLogParser/activityparser/ActivityParser.swift +++ b/Sources/XCLogParser/activityparser/ActivityParser.swift @@ -436,14 +436,12 @@ public class ActivityParser { backtrace: try parseAsJson(token: iterator.next(), type: jsonType)) case .some("BuildOperationMetrics"): - let jsonType = IDEActivityLogSectionAttachment.BuildOperationMetrics.self return try IDEActivityLogSectionAttachment(identifier: identifier, majorVersion: try parseAsInt(token: iterator.next()), minorVersion: try parseAsInt(token: iterator.next()), metrics: nil, - buildOperationMetrics: try parseAsJson( - token: iterator.next(), - type: jsonType + buildOperationMetrics: try parseBuildOperationMetrics( + token: iterator.next() ), backtrace: nil) default: @@ -693,6 +691,25 @@ public class ActivityParser { } } + private func parseBuildOperationMetrics( + token: Token? + ) throws -> IDEActivityLogSectionAttachment.BuildOperationMetrics? { + guard let token = token else { + throw XCLogParserError.parseError("Unexpected EOF parsing BuildOperationMetrics") + } + switch token { + case .json(let string): + guard let data = string.data(using: .utf8) else { + throw XCLogParserError.parseError("Unexpected JSON string \(string)") + } + return try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data) + case .null: + return nil + default: + throw XCLogParserError.parseError("Unexpected token parsing BuildOperationMetrics: \(token)") + } + } + private func parseAsInt(token: Token?) throws -> UInt64 { guard let token = token else { throw XCLogParserError.parseError("Unexpected EOF parsing Int") diff --git a/Sources/XCLogParser/activityparser/IDEActivityModel.swift b/Sources/XCLogParser/activityparser/IDEActivityModel.swift index d7f9f71..38e915b 100644 --- a/Sources/XCLogParser/activityparser/IDEActivityModel.swift +++ b/Sources/XCLogParser/activityparser/IDEActivityModel.swift @@ -773,30 +773,42 @@ public class IDEActivityLogSectionAttachment: Encodable { // Empty struct for objects with no properties } - public struct BuildOperationMetrics: Codable { - public let clangCacheHits: Int - public let clangCacheMisses: Int - public let swiftCacheHits: Int - public let swiftCacheMisses: Int - - public init( - clangCacheHits: Int = 0, - clangCacheMisses: Int = 0, - swiftCacheHits: Int = 0, - swiftCacheMisses: Int = 0 - ) { - self.clangCacheHits = clangCacheHits - self.clangCacheMisses = clangCacheMisses - self.swiftCacheHits = swiftCacheHits - self.swiftCacheMisses = swiftCacheMisses + /// Build operation metrics whose JSON schema differs by Xcode version. + public enum BuildOperationMetrics: Encodable { + /// Xcode 15.3 - Xcode 16.x format + case v15_3(CacheMetrics) + /// Xcode 26.4+ format + case v26_4(CounterMetrics) + + public struct CacheMetrics: Codable { + public let clangCacheHits: Int + public let clangCacheMisses: Int + public let swiftCacheHits: Int + public let swiftCacheMisses: Int } - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - clangCacheHits = try container.decodeIfPresent(Int.self, forKey: .clangCacheHits) ?? 0 - clangCacheMisses = try container.decodeIfPresent(Int.self, forKey: .clangCacheMisses) ?? 0 - swiftCacheHits = try container.decodeIfPresent(Int.self, forKey: .swiftCacheHits) ?? 0 - swiftCacheMisses = try container.decodeIfPresent(Int.self, forKey: .swiftCacheMisses) ?? 0 + public struct CounterMetrics: Codable { + public let counters: [String: Int] + public let taskCounters: [String: [String: Int]] + } + + public init(from jsonData: Data) throws { + let decoder = JSONDecoder() + if let cache = try? decoder.decode(CacheMetrics.self, from: jsonData) { + self = .v15_3(cache) + } else { + let counter = try decoder.decode(CounterMetrics.self, from: jsonData) + self = .v26_4(counter) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .v15_3(let metrics): + try metrics.encode(to: encoder) + case .v26_4(let metrics): + try metrics.encode(to: encoder) + } } } } diff --git a/Tests/XCLogParserTests/ActivityParserTests.swift b/Tests/XCLogParserTests/ActivityParserTests.swift index c845c63..b95860b 100644 --- a/Tests/XCLogParserTests/ActivityParserTests.swift +++ b/Tests/XCLogParserTests/ActivityParserTests.swift @@ -420,7 +420,11 @@ class ActivityParserTests: XCTestCase { XCTAssertEqual(3, logSection.attachments.count) XCTAssertEqual(logSection.attachments[0].backtrace?.frames.first?.category, .ruleNeverBuilt) print(logSection.attachments) - XCTAssertEqual(logSection.attachments[1].buildOperationMetrics?.clangCacheMisses, 2) + if case .some(.v15_3(let cache)) = logSection.attachments[1].buildOperationMetrics { + XCTAssertEqual(cache.clangCacheMisses, 2) + } else { + XCTFail("Expected v15_3 BuildOperationMetrics") + } XCTAssertEqual(logSection.attachments[2].metrics?.wcDuration, 1) XCTAssertEqual(0, logSection.unknown) } @@ -506,14 +510,12 @@ class ActivityParserTests: XCTestCase { XCTAssertEqual("localizedResultString", logSection.localizedResultString) XCTAssertEqual("xcbuildSignature", logSection.xcbuildSignature) XCTAssertEqual(1, logSection.attachments.count) - let metrics = logSection.attachments[0].buildOperationMetrics - XCTAssertNotNil(metrics) - XCTAssertNil(metrics?.clangCacheHits) - XCTAssertNil(metrics?.clangCacheMisses) - XCTAssertNil(metrics?.swiftCacheHits) - XCTAssertNil(metrics?.swiftCacheMisses) - XCTAssertEqual(metrics?.counters, [:]) - XCTAssertEqual(metrics?.taskCounters?["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1) + if case .some(.v26_4(let counter)) = logSection.attachments[0].buildOperationMetrics { + XCTAssertEqual(counter.counters, [:]) + XCTAssertEqual(counter.taskCounters["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1) + } else { + XCTFail("Expected v26_4 BuildOperationMetrics") + } XCTAssertEqual(42, logSection.unknown) } @@ -634,30 +636,30 @@ class ActivityParserTests: XCTestCase { XCTAssertEqual(expectedDVTMemberDocumentLocation, documentMemberLocation) } - func testBuildOperationMetricsWithMissingKeys() throws { - let json = #"{}"# + func testBuildOperationMetricsWithCacheFormat() throws { + let json = #"{"clangCacheHits":1,"clangCacheMisses":2,"swiftCacheHits":3,"swiftCacheMisses":4}"# let data = json.data(using: .utf8)! - let metrics = try JSONDecoder().decode( - IDEActivityLogSectionAttachment.BuildOperationMetrics.self, - from: data - ) - XCTAssertEqual(metrics.clangCacheHits, 0) - XCTAssertEqual(metrics.clangCacheMisses, 0) - XCTAssertEqual(metrics.swiftCacheHits, 0) - XCTAssertEqual(metrics.swiftCacheMisses, 0) + let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data) + if case .v15_3(let cache) = metrics { + XCTAssertEqual(cache.clangCacheHits, 1) + XCTAssertEqual(cache.clangCacheMisses, 2) + XCTAssertEqual(cache.swiftCacheHits, 3) + XCTAssertEqual(cache.swiftCacheMisses, 4) + } else { + XCTFail("Expected v15_3 BuildOperationMetrics") + } } - func testBuildOperationMetricsWithPartialKeys() throws { - let json = #"{"swiftCacheHits":5,"swiftCacheMisses":3}"# + func testBuildOperationMetricsWithCounterFormat() throws { + let json = #"{"counters":{"a":1},"taskCounters":{"SwiftDriver":{"x":2}}}"# let data = json.data(using: .utf8)! - let metrics = try JSONDecoder().decode( - IDEActivityLogSectionAttachment.BuildOperationMetrics.self, - from: data - ) - XCTAssertEqual(metrics.clangCacheHits, 0) - XCTAssertEqual(metrics.clangCacheMisses, 0) - XCTAssertEqual(metrics.swiftCacheHits, 5) - XCTAssertEqual(metrics.swiftCacheMisses, 3) + let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data) + if case .v26_4(let counter) = metrics { + XCTAssertEqual(counter.counters["a"], 1) + XCTAssertEqual(counter.taskCounters["SwiftDriver"]?["x"], 2) + } else { + XCTFail("Expected v26_4 BuildOperationMetrics") + } } } diff --git a/docs/JSON Format.md b/docs/JSON Format.md index 4ce936a..df13209 100644 --- a/docs/JSON Format.md +++ b/docs/JSON Format.md @@ -135,7 +135,7 @@ Aggregate build operation metrics. The JSON format differs by Xcode version: } ``` -All fields in `buildOperationMetrics` are optional to support both formats. +XCLogParser automatically detects and supports both formats. ### BuildOperationTaskBacktrace diff --git a/docs/Xcactivitylog Format.md b/docs/Xcactivitylog Format.md index 71de6b0..5c7bb88 100644 --- a/docs/Xcactivitylog Format.md +++ b/docs/Xcactivitylog Format.md @@ -189,4 +189,4 @@ Starting with **Xcode 26.4**, the format changed to a dynamic counter-based stru {"counters":{},"taskCounters":{"SwiftDriver":{"moduleDependenciesNotValidatedTasks":1}}} ``` -XCLogParser supports both formats. All fields are optional to maintain backward compatibility. +XCLogParser automatically detects and supports both formats. From dae100ddab9b139d74c48389354f9ad7f7fa3705 Mon Sep 17 00:00:00 2001 From: giginet Date: Tue, 24 Mar 2026 08:13:08 +0900 Subject: [PATCH 4/5] refactor: unify BuildOperationMetrics into a single struct with counters Instead of using an enum with separate CacheMetrics and CounterMetrics associated values, use a single struct with counters/taskCounters fields. The legacy Xcode 15.3 - Xcode 26.3 cache format (clangCacheHits, etc.) is translated into the unified counter format during deserialization. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: giginet --- .../activityparser/IDEActivityModel.swift | 50 +++++++++---------- .../ActivityParserTests.swift | 35 ++++++------- 2 files changed, 38 insertions(+), 47 deletions(-) diff --git a/Sources/XCLogParser/activityparser/IDEActivityModel.swift b/Sources/XCLogParser/activityparser/IDEActivityModel.swift index 38e915b..1edf908 100644 --- a/Sources/XCLogParser/activityparser/IDEActivityModel.swift +++ b/Sources/XCLogParser/activityparser/IDEActivityModel.swift @@ -774,41 +774,39 @@ public class IDEActivityLogSectionAttachment: Encodable { } /// Build operation metrics whose JSON schema differs by Xcode version. - public enum BuildOperationMetrics: Encodable { - /// Xcode 15.3 - Xcode 16.x format - case v15_3(CacheMetrics) - /// Xcode 26.4+ format - case v26_4(CounterMetrics) - - public struct CacheMetrics: Codable { - public let clangCacheHits: Int - public let clangCacheMisses: Int - public let swiftCacheHits: Int - public let swiftCacheMisses: Int - } + public struct BuildOperationMetrics: Codable { + public let counters: [String: Int] + public let taskCounters: [String: [String: Int]] - public struct CounterMetrics: Codable { - public let counters: [String: Int] - public let taskCounters: [String: [String: Int]] + public init(counters: [String: Int], taskCounters: [String: [String: Int]]) { + self.counters = counters + self.taskCounters = taskCounters } + /// Parses BuildOperationMetrics from JSON data, translating the legacy + /// Xcode 15.3 - Xcode 26.3 cache format into the unified counter format. public init(from jsonData: Data) throws { let decoder = JSONDecoder() - if let cache = try? decoder.decode(CacheMetrics.self, from: jsonData) { - self = .v15_3(cache) + if let direct = try? decoder.decode(BuildOperationMetrics.self, from: jsonData) { + self = direct } else { - let counter = try decoder.decode(CounterMetrics.self, from: jsonData) - self = .v26_4(counter) + let legacy = try decoder.decode(LegacyCacheMetrics.self, from: jsonData) + self.counters = [ + "clangCacheHits": legacy.clangCacheHits, + "clangCacheMisses": legacy.clangCacheMisses, + "swiftCacheHits": legacy.swiftCacheHits, + "swiftCacheMisses": legacy.swiftCacheMisses, + ] + self.taskCounters = [:] } } - public func encode(to encoder: Encoder) throws { - switch self { - case .v15_3(let metrics): - try metrics.encode(to: encoder) - case .v26_4(let metrics): - try metrics.encode(to: encoder) - } + /// Legacy Xcode 15.3 - Xcode 26.3 format, used only for deserialization. + private struct LegacyCacheMetrics: Decodable { + let clangCacheHits: Int + let clangCacheMisses: Int + let swiftCacheHits: Int + let swiftCacheMisses: Int } } } diff --git a/Tests/XCLogParserTests/ActivityParserTests.swift b/Tests/XCLogParserTests/ActivityParserTests.swift index b95860b..75c805c 100644 --- a/Tests/XCLogParserTests/ActivityParserTests.swift +++ b/Tests/XCLogParserTests/ActivityParserTests.swift @@ -420,10 +420,10 @@ class ActivityParserTests: XCTestCase { XCTAssertEqual(3, logSection.attachments.count) XCTAssertEqual(logSection.attachments[0].backtrace?.frames.first?.category, .ruleNeverBuilt) print(logSection.attachments) - if case .some(.v15_3(let cache)) = logSection.attachments[1].buildOperationMetrics { - XCTAssertEqual(cache.clangCacheMisses, 2) + if let metrics = logSection.attachments[1].buildOperationMetrics { + XCTAssertEqual(metrics.counters["clangCacheMisses"], 2) } else { - XCTFail("Expected v15_3 BuildOperationMetrics") + XCTFail("Expected BuildOperationMetrics") } XCTAssertEqual(logSection.attachments[2].metrics?.wcDuration, 1) XCTAssertEqual(0, logSection.unknown) @@ -510,11 +510,11 @@ class ActivityParserTests: XCTestCase { XCTAssertEqual("localizedResultString", logSection.localizedResultString) XCTAssertEqual("xcbuildSignature", logSection.xcbuildSignature) XCTAssertEqual(1, logSection.attachments.count) - if case .some(.v26_4(let counter)) = logSection.attachments[0].buildOperationMetrics { - XCTAssertEqual(counter.counters, [:]) - XCTAssertEqual(counter.taskCounters["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1) + if let metrics = logSection.attachments[0].buildOperationMetrics { + XCTAssertEqual(metrics.counters, [:]) + XCTAssertEqual(metrics.taskCounters["SwiftDriver"]?["moduleDependenciesNotValidatedTasks"], 1) } else { - XCTFail("Expected v26_4 BuildOperationMetrics") + XCTFail("Expected BuildOperationMetrics") } XCTAssertEqual(42, logSection.unknown) } @@ -640,26 +640,19 @@ class ActivityParserTests: XCTestCase { let json = #"{"clangCacheHits":1,"clangCacheMisses":2,"swiftCacheHits":3,"swiftCacheMisses":4}"# let data = json.data(using: .utf8)! let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data) - if case .v15_3(let cache) = metrics { - XCTAssertEqual(cache.clangCacheHits, 1) - XCTAssertEqual(cache.clangCacheMisses, 2) - XCTAssertEqual(cache.swiftCacheHits, 3) - XCTAssertEqual(cache.swiftCacheMisses, 4) - } else { - XCTFail("Expected v15_3 BuildOperationMetrics") - } + XCTAssertEqual(metrics.counters["clangCacheHits"], 1) + XCTAssertEqual(metrics.counters["clangCacheMisses"], 2) + XCTAssertEqual(metrics.counters["swiftCacheHits"], 3) + XCTAssertEqual(metrics.counters["swiftCacheMisses"], 4) + XCTAssertEqual(metrics.taskCounters, [:]) } func testBuildOperationMetricsWithCounterFormat() throws { let json = #"{"counters":{"a":1},"taskCounters":{"SwiftDriver":{"x":2}}}"# let data = json.data(using: .utf8)! let metrics = try IDEActivityLogSectionAttachment.BuildOperationMetrics(from: data) - if case .v26_4(let counter) = metrics { - XCTAssertEqual(counter.counters["a"], 1) - XCTAssertEqual(counter.taskCounters["SwiftDriver"]?["x"], 2) - } else { - XCTFail("Expected v26_4 BuildOperationMetrics") - } + XCTAssertEqual(metrics.counters["a"], 1) + XCTAssertEqual(metrics.taskCounters["SwiftDriver"]?["x"], 2) } } From b291bac3e0c82edd9896be1342e4dfa6c88b11f6 Mon Sep 17 00:00:00 2001 From: giginet Date: Thu, 26 Mar 2026 14:32:35 +0900 Subject: [PATCH 5/5] fix: move LegacyCacheMetrics out of BuildOperationMetrics to fix SwiftLint nesting violation Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: giginet --- .../activityparser/IDEActivityModel.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/XCLogParser/activityparser/IDEActivityModel.swift b/Sources/XCLogParser/activityparser/IDEActivityModel.swift index 1edf908..a2bd441 100644 --- a/Sources/XCLogParser/activityparser/IDEActivityModel.swift +++ b/Sources/XCLogParser/activityparser/IDEActivityModel.swift @@ -800,13 +800,13 @@ public class IDEActivityLogSectionAttachment: Encodable { self.taskCounters = [:] } } - - /// Legacy Xcode 15.3 - Xcode 26.3 format, used only for deserialization. - private struct LegacyCacheMetrics: Decodable { - let clangCacheHits: Int - let clangCacheMisses: Int - let swiftCacheHits: Int - let swiftCacheMisses: Int - } } } + +/// Legacy Xcode 15.3 - Xcode 26.3 BuildOperationMetrics format, used only for deserialization. +private struct LegacyCacheMetrics: Decodable { + let clangCacheHits: Int + let clangCacheMisses: Int + let swiftCacheHits: Int + let swiftCacheMisses: Int +}