diff --git a/.gitignore b/.gitignore index 738c04c..886aa71 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ DerivedData/ # Xcode 11 .swiftpm/ +RealTests/ diff --git a/Sources/XCLogParser/commands/CommandHandler.swift b/Sources/XCLogParser/commands/CommandHandler.swift index dab1c2d..83a4ec9 100644 --- a/Sources/XCLogParser/commands/CommandHandler.swift +++ b/Sources/XCLogParser/commands/CommandHandler.swift @@ -71,6 +71,17 @@ public struct CommandHandler { let reporterOutput = ReporterOutputFactory.makeReporterOutput(path: options.outputPath) let logReporter = options.reporter.makeLogReporter() try logReporter.report(build: buildSteps, output: reporterOutput, rootOutput: options.rootOutput) + + // Exit with appropriate code based on build status + if shouldExitWithFailureCode(buildSteps: buildSteps) { + exit(1) + } + } + + private func shouldExitWithFailureCode(buildSteps: BuildStep) -> Bool { + // Check if the main build failed + let buildStatus = buildSteps.buildStatus.lowercased() + return buildStatus.contains("failed") || buildStatus.contains("error") } } diff --git a/Sources/XCLogParser/parser/BuildStep.swift b/Sources/XCLogParser/parser/BuildStep.swift index 4c082f5..9be2d71 100644 --- a/Sources/XCLogParser/parser/BuildStep.swift +++ b/Sources/XCLogParser/parser/BuildStep.swift @@ -98,6 +98,8 @@ public enum DetailStepType: String, Encodable { return .cCompilation case Prefix("CompileSwift "): return .swiftCompilation + case Prefix("SwiftCompile "): + return .swiftCompilation case Prefix("Ld "): return .linker case Prefix("PhaseScriptExecution "): diff --git a/Sources/XCLogParser/parser/Notice+Parser.swift b/Sources/XCLogParser/parser/Notice+Parser.swift index 54dc28f..a44e03c 100644 --- a/Sources/XCLogParser/parser/Notice+Parser.swift +++ b/Sources/XCLogParser/parser/Notice+Parser.swift @@ -31,7 +31,8 @@ extension Notice { /// - returns: An Array of `Notice` public static func parseFromLogSection(_ logSection: IDEActivityLogSection, forType type: DetailStepType, - truncLargeIssues: Bool) + truncLargeIssues: Bool, + isSubsection: Bool = false) -> [Notice] { var logSection = logSection if truncLargeIssues && logSection.messages.count > 100 { @@ -60,7 +61,9 @@ extension Notice { // Special case, Interface builder warning can only be spotted by checking the whole text of the // log section let noticeTypeTitle = message.categoryIdent.isEmpty ? logSection.text : message.categoryIdent - if var notice = Notice(withType: NoticeType.fromTitle(noticeTypeTitle), + let initialType = NoticeType.fromTitleAndSeverity(noticeTypeTitle, severity: message.severity) + + if var notice = Notice(withType: initialType, logMessage: message, detail: logSection.text) { // Add the right details to Swift errors @@ -73,8 +76,8 @@ extension Notice { var errorLocation = notice.documentURL.replacingOccurrences(of: "file://", with: "") errorLocation += ":\(notice.startingLineNumber):\(notice.startingColumnNumber):" // do not report error in a file that it does not belong to (we'll ended - // up having duplicated errors) - if !logSection.location.documentURLString.isEmpty + // up having duplicated errors) - but only for main sections, not subsections + if !isSubsection && !logSection.location.documentURLString.isEmpty && logSection.location.documentURLString != notice.documentURL { return nil } @@ -164,8 +167,17 @@ extension Notice { } return zip(logSection.messages, clangFlags) .compactMap { (message, warningFlag) -> Notice? in - // If the warning is treated as error, we marked the issue as error - let type: NoticeType = warningFlag.contains("-Werror") ? .clangError : .clangWarning + // Determine type based on both flags and severity + var type: NoticeType + if warningFlag.contains("-Werror") { + type = .clangError + } else { + // Use severity to determine if this should be treated as error or warning + // Severity 2+ = error (treated as error by compiler) + // Severity 1 = warning + type = message.severity >= 2 ? .clangError : .clangWarning + } + let notice = Notice(withType: type, logMessage: message, clangFlag: warningFlag) if let notice = notice, diff --git a/Sources/XCLogParser/parser/NoticeType.swift b/Sources/XCLogParser/parser/NoticeType.swift index 9f3bfa2..24ade6d 100644 --- a/Sources/XCLogParser/parser/NoticeType.swift +++ b/Sources/XCLogParser/parser/NoticeType.swift @@ -94,8 +94,48 @@ public enum NoticeType: String, Codable { return .swiftError case Suffix("failed with a nonzero exit code"): return .failedCommandError + case "No-usage": + return .swiftWarning default: return .note } } + + /// Returns a NoticeType based on both categoryIdent title and severity level + /// This method handles ambiguous categories that can be either warnings or errors + /// Severity levels: 0 = note, 1 = warning, 2+ = error (treated as error by compiler) + public static func fromTitleAndSeverity(_ title: String, severity: Int) -> NoticeType? { + // First get the initial type from title + guard let initialType = fromTitle(title) else { + return nil + } + + // Use severity to override type classification when needed + switch severity { + case 0: + return .note + case 1: + switch initialType { + case .clangError: + return .clangWarning + case .swiftError: + return .swiftWarning + default: + return initialType + } + case 2...: + switch initialType { + case .clangWarning, .projectWarning: + return .clangError + case .swiftWarning: + return .swiftError + case .note: + return .error + default: + return initialType + } + default: + return initialType + } + } } diff --git a/Sources/XCLogParser/parser/ParserBuildSteps.swift b/Sources/XCLogParser/parser/ParserBuildSteps.swift index cdea461..ee0788f 100644 --- a/Sources/XCLogParser/parser/ParserBuildSteps.swift +++ b/Sources/XCLogParser/parser/ParserBuildSteps.swift @@ -98,6 +98,31 @@ public final class ParserBuildSteps { self.truncLargeIssues = truncLargeIssues } + /// Collects all warnings and errors from a BuildStep tree and deduplicates them + /// - parameter buildStep: The root BuildStep to collect notices from + /// - returns: A tuple of (warnings, errors) arrays with duplicates removed + private func collectAndDeduplicateNotices(from buildStep: BuildStep) -> ([Notice], [Notice]) { + var allWarnings: [Notice] = [] + var allErrors: [Notice] = [] + + func collectNotices(from step: BuildStep) { + if let warnings = step.warnings { + allWarnings.append(contentsOf: warnings) + } + if let errors = step.errors { + allErrors.append(contentsOf: errors) + } + + for subStep in step.subSteps { + collectNotices(from: subStep) + } + } + + collectNotices(from: buildStep) + + return (allWarnings.removingDuplicates(), allErrors.removingDuplicates()) + } + /// Parses the content from an Xcode log into a `BuildStep` /// - parameter activityLog: An `IDEActivityLog` /// - returns: A `BuildStep` with the parsed content from the log. @@ -106,8 +131,12 @@ public final class ParserBuildSteps { buildStatus = BuildStatusSanitizer.sanitize(originalStatus: activityLog.mainSection.localizedResultString) let mainSectionWithTargets = activityLog.mainSection.groupedByTarget() var mainBuildStep = try parseLogSection(logSection: mainSectionWithTargets, type: .main, parentSection: nil) - mainBuildStep.errorCount = totalErrors - mainBuildStep.warningCount = totalWarnings + + // Collect and deduplicate all warnings and errors from the entire build tree + let (deduplicatedWarnings, deduplicatedErrors) = collectAndDeduplicateNotices(from: mainBuildStep) + mainBuildStep.errorCount = deduplicatedErrors.count + mainBuildStep.warningCount = deduplicatedWarnings.count + mainBuildStep = decorateWithSwiftcTimes(mainBuildStep) return mainBuildStep } @@ -131,7 +160,41 @@ public final class ParserBuildSteps { targetErrors = 0 targetWarnings = 0 } - let notices = parseWarningsAndErrorsFromLogSection(logSection, forType: detailType) + var notices = parseWarningsAndErrorsFromLogSection(logSection, forType: detailType) + + + // For Swift compilations and other compilation types, also check subsections for errors/warnings + // Also ensure Swift file-level compilations are processed correctly + if (detailType == .swiftCompilation || detailType == .cCompilation || detailType == .other) && !logSection.subSections.isEmpty { + // Initialize notices if nil + if notices == nil { + notices = ["warnings": [], "errors": [], "notes": []] + } + + for subSection in logSection.subSections { + if let subNotices = parseWarningsAndErrorsFromLogSectionAsSubsection(subSection, forType: detailType) { + // Merge subsection notices with parent section notices + if let parentWarnings = notices?["warnings"], let subWarnings = subNotices["warnings"] { + notices?["warnings"] = parentWarnings + subWarnings + } else if let subWarnings = subNotices["warnings"] { + notices?["warnings"] = subWarnings + } + + if let parentErrors = notices?["errors"], let subErrors = subNotices["errors"] { + notices?["errors"] = parentErrors + subErrors + } else if let subErrors = subNotices["errors"] { + notices?["errors"] = subErrors + } + + if let parentNotes = notices?["notes"], let subNotes = subNotices["notes"] { + notices?["notes"] = parentNotes + subNotes + } else if let subNotes = subNotices["notes"] { + notices?["notes"] = subNotes + } + } + } + } + let warnings: [Notice]? = notices?["warnings"] let errors: [Notice]? = notices?["errors"] let notes: [Notice]? = notices?["notes"] @@ -146,6 +209,7 @@ public final class ParserBuildSteps { totalWarnings += warnings.count targetWarnings += warnings.count } + var step = BuildStep(type: type, machineName: machineName, buildIdentifier: self.buildIdentifier, @@ -310,6 +374,14 @@ public final class ParserBuildSteps { "errors": notices.getErrors(), "notes": notices.getNotes()] } + + private func parseWarningsAndErrorsFromLogSectionAsSubsection(_ logSection: IDEActivityLogSection, forType type: DetailStepType) + -> [String: [Notice]]? { + let notices = Notice.parseFromLogSection(logSection, forType: type, truncLargeIssues: truncLargeIssues, isSubsection: true) + return ["warnings": notices.getWarnings(), + "errors": notices.getErrors(), + "notes": notices.getNotes()] + } private func decorateWithSwiftcTimes(_ mainStep: BuildStep) -> BuildStep { swiftCompilerParser.parse() diff --git a/Tests/XCLogParserTests/ParserTests.swift b/Tests/XCLogParserTests/ParserTests.swift index a0e978a..3672dd9 100644 --- a/Tests/XCLogParserTests/ParserTests.swift +++ b/Tests/XCLogParserTests/ParserTests.swift @@ -532,6 +532,100 @@ note: use 'updatedDoSomething' instead\r doSomething()\r ^~~~~~~~~~~\r XCTAssertEqual(100, build.warnings?.count ?? 0, "Warnings should be truncated up to 100") } + func testAmbiguousCategoryMessagesShouldRespectSeverity() throws { + let timestamp = Date().timeIntervalSinceReferenceDate + let textDocumentLocation = DVTTextDocumentLocation(documentURLString: "file:///project/test.h", + timestamp: timestamp, + startingLineNumber: 348, + startingColumnNumber: 15, + endingLineNumber: 348, + endingColumnNumber: 15, + characterRangeEnd: 18446744073709551615, + characterRangeStart: 0, + locationEncoding: 0) + + // Create a Parse Issue message with severity 1 (should be warning) + let parseIssueWarning = IDEActivityLogMessage(title: "Constexpr if is a C++17 extension", + shortTitle: "", + timeEmitted: timestamp, + rangeEndInSectionText: 18446744073709551615, + rangeStartInSectionText: 0, + subMessages: [], + severity: 1, + type: "com.apple.dt.IDE.diagnostic", + location: textDocumentLocation, + categoryIdent: "Parse Issue", + secondaryLocations: [], + additionalDescription: "") + + // Create a Parse Issue message with severity 2 (should be error) + let parseIssueError = IDEActivityLogMessage(title: "Unknown type 'InvalidType'", + shortTitle: "", + timeEmitted: timestamp, + rangeEndInSectionText: 18446744073709551615, + rangeStartInSectionText: 0, + subMessages: [], + severity: 2, + type: "com.apple.dt.IDE.diagnostic", + location: textDocumentLocation, + categoryIdent: "Parse Issue", + secondaryLocations: [], + additionalDescription: "") + + // Test that Semantic Issue also works (another ambiguous category) + let semanticIssueWarning = IDEActivityLogMessage(title: "Implicit conversion warning", + shortTitle: "", + timeEmitted: timestamp, + rangeEndInSectionText: 18446744073709551615, + rangeStartInSectionText: 0, + subMessages: [], + severity: 1, + type: "com.apple.dt.IDE.diagnostic", + location: textDocumentLocation, + categoryIdent: "Semantic Issue", + secondaryLocations: [], + additionalDescription: "") + + let fakeLog = getFakeIDEActivityLogWithMessages([parseIssueWarning, parseIssueError, semanticIssueWarning], + andText: "test text", + loc: textDocumentLocation) + let build = try parser.parse(activityLog: fakeLog) + + // Verify counts + XCTAssertEqual(2, build.warningCount, "Should have 2 warnings (Parse Issue + Semantic Issue with severity 1)") + XCTAssertEqual(1, build.errorCount, "Should have 1 error from Parse Issue with severity 2") + + // Verify warnings + XCTAssertNotNil(build.warnings, "Warnings shouldn't be empty") + XCTAssertEqual(2, build.warnings?.count ?? 0, "Should have 2 warnings") + + // Find the Parse Issue warning + guard let parseWarning = build.warnings?.first(where: { $0.title == parseIssueWarning.title }) else { + XCTFail("Parse Issue warning not found") + return + } + XCTAssertEqual(NoticeType.clangWarning, parseWarning.type, "Parse Issue with severity 1 should be clangWarning") + XCTAssertEqual(1, parseWarning.severity, "Parse Issue warning should have severity 1") + + // Find the Semantic Issue warning + guard let semanticWarning = build.warnings?.first(where: { $0.title == semanticIssueWarning.title }) else { + XCTFail("Semantic Issue warning not found") + return + } + XCTAssertEqual(NoticeType.clangWarning, semanticWarning.type, "Semantic Issue with severity 1 should be clangWarning") + XCTAssertEqual(1, semanticWarning.severity, "Semantic Issue warning should have severity 1") + + // Verify error + XCTAssertNotNil(build.errors, "Errors shouldn't be empty") + guard let error = build.errors?.first else { + XCTFail("Build's errors are empty") + return + } + XCTAssertEqual(parseIssueError.title, error.title) + XCTAssertEqual(NoticeType.clangError, error.type, "Parse Issue with severity 2 should be clangError") + XCTAssertEqual(2, error.severity, "Error should have severity 2") + } + // swiftlint:disable line_length let commandDetailSwiftSteps = """ CompileSwift normal x86_64 (in target 'Alamofire' from project 'Pods') @@ -561,4 +655,89 @@ CompileSwift normal x86_64 (in target 'Alamofire' from project 'Pods') attachments: [], unknown: 0) }() + + func testSwiftCompilationErrorInSubsection() throws { + // Test that Swift compilation errors in subsections are properly detected + let errorMessage = IDEActivityLogMessage( + title: "Cannot find type 'UnknownType' in scope", + shortTitle: "", + timeEmitted: 1.0, + rangeEndInSectionText: UInt64.max, + rangeStartInSectionText: 0, + subMessages: [], + severity: 2, + type: "com.apple.dt.IDE.diagnostic", + location: DVTTextDocumentLocation( + documentURLString: "file:///path/to/TestFile.swift", + timestamp: 1.0, + startingLineNumber: 10, + startingColumnNumber: 20, + endingLineNumber: 10, + endingColumnNumber: 30, + characterRangeEnd: 100, + characterRangeStart: 90, + locationEncoding: 0 + ), + categoryIdent: "Swift Compiler Error", + secondaryLocations: [], + additionalDescription: "" + ) + + let subsection = IDEActivityLogSection( + sectionType: 2, + domainType: "", + title: "Compile TestFile.swift (arm64)", + signature: "", + timeStartedRecording: 1.0, + timeStoppedRecording: 2.0, + subSections: [], + text: "", + messages: [errorMessage], + wasCancelled: false, + isQuiet: false, + wasFetchedFromCache: false, + subtitle: "", + location: DVTDocumentLocation(documentURLString: "", timestamp: 0), + commandDetailDesc: "", + uniqueIdentifier: "", + localizedResultString: "", + xcbuildSignature: "", + attachments: [], + unknown: 0 + ) + + let swiftCompileSection = IDEActivityLogSection( + sectionType: 2, + domainType: "", + title: "Compiling TestFile.swift", + signature: "SwiftCompile normal arm64 Compiling\\ TestFile.swift /path/to/TestFile.swift (in target 'TestTarget' from project 'TestProject')", + timeStartedRecording: 1.0, + timeStoppedRecording: 2.0, + subSections: [subsection], + text: "", + messages: [], + wasCancelled: false, + isQuiet: false, + wasFetchedFromCache: false, + subtitle: "", + location: DVTDocumentLocation(documentURLString: "", timestamp: 0), + commandDetailDesc: "", + uniqueIdentifier: "", + localizedResultString: "", + xcbuildSignature: "", + attachments: [], + unknown: 0 + ) + + let step = try parser.parseLogSection( + logSection: swiftCompileSection, + type: .detail, + parentSection: nil + ) + + // Should detect the error from the subsection + XCTAssertEqual(step.errorCount, 1, "Should detect 1 error from subsection") + XCTAssertEqual(step.errors?.count, 1, "Should have 1 error in errors array") + XCTAssertEqual(step.errors?.first?.title, "Cannot find type 'UnknownType' in scope", "Should have correct error title") + } }