From 72a437c945918180a648e89f732368316667dfe1 Mon Sep 17 00:00:00 2001 From: fortmarek Date: Fri, 24 Apr 2026 14:54:49 +0200 Subject: [PATCH 1/2] fix: ignore stale substep timestamps when computing target compilation duration For incremental builds, Xcode reuses substep entries from previous build sessions for files that don't need recompilation. Those substeps keep their original (older) timestamps and are not flagged `wasFetchedFromCache`. The previous logic in `addCompilationTimesToTarget` took the latest substep `compilationEndTimestamp` regardless of whether it actually ran in this build session, which produced a negative `compilationDuration` whenever the chosen substep predated the target's `startTimestamp`. Filter out such stale substeps by requiring `compilationEndTimestamp >= parent.startTimestamp` in both `addCompilationTimesToTarget` and `addCompilationTimesToApp`. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: fortmarek --- .../XCLogParser/parser/ParserBuildSteps.swift | 16 ++++++++++-- Tests/XCLogParserTests/ParserTests.swift | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Sources/XCLogParser/parser/ParserBuildSteps.swift b/Sources/XCLogParser/parser/ParserBuildSteps.swift index cdea461..fd21474 100644 --- a/Sources/XCLogParser/parser/ParserBuildSteps.swift +++ b/Sources/XCLogParser/parser/ParserBuildSteps.swift @@ -373,8 +373,16 @@ public final class ParserBuildSteps { private func addCompilationTimesToTarget(_ target: BuildStep) -> BuildStep { + // For incremental builds, Xcode reuses substep entries from prior build sessions for files + // that didn't need recompilation. Those substeps keep their original (older) timestamps and + // are not flagged `wasFetchedFromCache`. Filtering them out by requiring the substep to + // have ended at or after the target started avoids producing a negative compilationDuration. let lastCompilationStep = target.subSteps - .filter { $0.isCompilationStep() && $0.fetchedFromCache == false } + .filter { + $0.isCompilationStep() + && $0.fetchedFromCache == false + && $0.compilationEndTimestamp >= target.startTimestamp + } .max { $0.compilationEndTimestamp < $1.compilationEndTimestamp } guard let lastStep = lastCompilationStep else { return target.with(newCompilationEndTimestamp: target.startTimestamp, andCompilationDuration: 0.0) @@ -385,7 +393,11 @@ public final class ParserBuildSteps { private func addCompilationTimesToApp(_ app: BuildStep) -> BuildStep { let lastCompilationStep = app.subSteps - .filter { $0.compilationDuration > 0 && $0.fetchedFromCache == false } + .filter { + $0.compilationDuration > 0 + && $0.fetchedFromCache == false + && $0.compilationEndTimestamp >= app.startTimestamp + } .max { $0.compilationEndTimestamp < $1.compilationEndTimestamp } guard let lastStep = lastCompilationStep else { return app.with(newCompilationEndTimestamp: app.startTimestamp, diff --git a/Tests/XCLogParserTests/ParserTests.swift b/Tests/XCLogParserTests/ParserTests.swift index a0e978a..5c01c54 100644 --- a/Tests/XCLogParserTests/ParserTests.swift +++ b/Tests/XCLogParserTests/ParserTests.swift @@ -346,6 +346,31 @@ note: use 'updatedDoSomething' instead\r doSomething()\r ^~~~~~~~~~~\r XCTAssertEqual(expectedCompilationDuration, targetStep.compilationDuration) } + func testParseTargetCompilationTimesIgnoresStaleSubsteps() { + // For incremental builds, Xcode reuses substep entries from previous build sessions for + // files that didn't need recompilation. Those substeps keep their original (older) + // timestamps and are not flagged `wasFetchedFromCache`. The target's compilationDuration + // must remain non-negative; substeps that ended before the target started belong to a + // prior session and should be ignored. + let now = Date().timeIntervalSince1970 + let staleCompileEnd = now - 1000 + let staleStep = makeFakeBuildStep(title: "Stale Compile", + type: .detail, + detailStepType: .cCompilation, + startTimestamp: now - 1010, + fetchedFromCache: false) + .with(newCompilationEndTimestamp: staleCompileEnd, andCompilationDuration: 10) + var targetStep = makeFakeBuildStep(title: "Build Target", + type: .target, + detailStepType: .none, + startTimestamp: now, + fetchedFromCache: false).with(subSteps: [staleStep]) + + targetStep = parser.addCompilationTimes(step: targetStep) + XCTAssertEqual(targetStep.startTimestamp, targetStep.compilationEndTimestamp) + XCTAssertEqual(0.0, targetStep.compilationDuration) + } + func testParseAppCompilationTimes() { let expectedCompilationDuration = 50.0 let now = Date().timeIntervalSince1970 From bf56c7cee4f4832ca6a608df9dac5c9b9c8dcd41 Mon Sep 17 00:00:00 2001 From: fortmarek Date: Fri, 24 Apr 2026 15:04:41 +0200 Subject: [PATCH 2/2] fix: keep ParserBuildSteps.swift under the 400-line SwiftLint limit Inline the new filter clause across two lines instead of expanding it into a four-line block, so the file stays at 400 lines (the SwiftLint file_length limit). Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: fortmarek --- .../XCLogParser/parser/ParserBuildSteps.swift | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/Sources/XCLogParser/parser/ParserBuildSteps.swift b/Sources/XCLogParser/parser/ParserBuildSteps.swift index fd21474..912c8bd 100644 --- a/Sources/XCLogParser/parser/ParserBuildSteps.swift +++ b/Sources/XCLogParser/parser/ParserBuildSteps.swift @@ -373,16 +373,9 @@ public final class ParserBuildSteps { private func addCompilationTimesToTarget(_ target: BuildStep) -> BuildStep { - // For incremental builds, Xcode reuses substep entries from prior build sessions for files - // that didn't need recompilation. Those substeps keep their original (older) timestamps and - // are not flagged `wasFetchedFromCache`. Filtering them out by requiring the substep to - // have ended at or after the target started avoids producing a negative compilationDuration. let lastCompilationStep = target.subSteps - .filter { - $0.isCompilationStep() - && $0.fetchedFromCache == false - && $0.compilationEndTimestamp >= target.startTimestamp - } + .filter { $0.isCompilationStep() && $0.fetchedFromCache == false && + $0.compilationEndTimestamp >= target.startTimestamp } .max { $0.compilationEndTimestamp < $1.compilationEndTimestamp } guard let lastStep = lastCompilationStep else { return target.with(newCompilationEndTimestamp: target.startTimestamp, andCompilationDuration: 0.0) @@ -393,11 +386,8 @@ public final class ParserBuildSteps { private func addCompilationTimesToApp(_ app: BuildStep) -> BuildStep { let lastCompilationStep = app.subSteps - .filter { - $0.compilationDuration > 0 - && $0.fetchedFromCache == false - && $0.compilationEndTimestamp >= app.startTimestamp - } + .filter { $0.compilationDuration > 0 && $0.fetchedFromCache == false && + $0.compilationEndTimestamp >= app.startTimestamp } .max { $0.compilationEndTimestamp < $1.compilationEndTimestamp } guard let lastStep = lastCompilationStep else { return app.with(newCompilationEndTimestamp: app.startTimestamp,