From 6bcd2b3dcdc116709350c4925bad6b3a3979ddcf Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 28 Aug 2026 12:43:31 +0500 Subject: [PATCH] fix: keep source-context echo out of diagnostics A `file:line:col: error:/warning:/note:` header is followed by the offending source line, indented, and a caret line. The echoed source can carry `: error: ` inside a string literal or a comment. The parser read those bytes as a build error, so a successful build got the `failed` verdict. Track the echo block as parser state. The block opens on a diagnostic header that carries a location. It closes on the caret line, or on the next line without indentation. Error and warning parsing is skipped only inside the block. Indentation alone must never suppress a diagnostic. Indented tool output such as `swiftgen: error: template not found` and an indented `Command PhaseScriptExecution failed with a nonzero exit code` stay reportable. Fixes #78. --- CLAUDE.md | 6 + Sources/XCSiftCore/LineParser.swift | 62 +++++++- Sources/XCSiftCore/XcodebuildSymbols.swift | 2 + .../SourceContextEchoTests.swift | 141 ++++++++++++++++++ 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 Tests/XCSiftCoreTests/SourceContextEchoTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 8ef10cd..acf7f49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,12 @@ The codebase follows a modular architecture: - For multi-line context, use **look-ahead/look-back** in the `parse()` loop (has access to `lines` array by index) - Existing examples: look-back for `PhaseScriptExecution` context, look-ahead for Swift Testing `#expect` comments - `failedTests` are deduplicated by normalized test name — duplicate names get merged, not appended +- **Source-context echo**: a `file:line:col: error:/warning:/note:` header opens a block that holds + the indented source line and the caret line. `LineParser.sourceContextOpen` tracks the block and + skips error/warning parsing inside it, because echoed source can carry `: error: ` in a string + literal. The block closes on the caret line or on the next line without indentation. Indentation + alone must never suppress a diagnostic: indented tool output (`swiftgen: error: …`) stays + reportable. ### Key Features - **Error/Warning Parsing**: Multiple regex patterns handle various Xcode error formats diff --git a/Sources/XCSiftCore/LineParser.swift b/Sources/XCSiftCore/LineParser.swift index 78a310d..2fbbf39 100644 --- a/Sources/XCSiftCore/LineParser.swift +++ b/Sources/XCSiftCore/LineParser.swift @@ -102,6 +102,12 @@ public struct LineParser: Sendable { private var pendingRecordedIssueLine: String? private var lookBackBuffer: [String] = [] + /// True while a compiler source-context block is open. A `file:line:col: error:/warning:/note:` + /// header is followed by the offending source line, indented, then a caret line. Echoed source + /// can carry `: error: ` inside a string literal or a comment, which is not a diagnostic. + /// Tracking the block keeps indented tool output — `swiftgen: error: …` — reportable. + private var sourceContextOpen = false + // MARK: - xcbeautify private let shouldParseXcbeautify: Bool private let shouldParseBuildInfo: Bool @@ -331,7 +337,7 @@ public struct LineParser: Sendable { // MARK: - Core dispatch private struct LineCandidates: OptionSet, Sendable { - let rawValue: UInt8 + let rawValue: UInt16 static let error = LineCandidates(rawValue: 1 << 0) static let warning = LineCandidates(rawValue: 1 << 1) @@ -341,6 +347,9 @@ public struct LineParser: Sendable { static let executable = LineCandidates(rawValue: 1 << 5) static let recordedIssue = LineCandidates(rawValue: 1 << 6) static let jsonSyntax = LineCandidates(rawValue: 1 << 7) + /// `note:` opens a source-context block but is not itself a reported diagnostic, so the + /// bit stays out of ``parserCategories``. + static let diagnosticNote = LineCandidates(rawValue: 1 << 8) static let parserCategories: LineCandidates = [ .error, .warning, .test, .status, .buildInfo, .executable, ] @@ -365,6 +374,7 @@ public struct LineParser: Sendable { add(XcodebuildSymbols.warningKeyword, candidates: .warning) add(XcodebuildSymbols.errorKeyword, candidates: .error) + add(XcodebuildSymbols.noteKeyword, candidates: .diagnosticNote) add(XcodebuildSymbols.failedKeyword, candidates: [.error, .test, .status]) add(XcodebuildSymbols.passedKeyword, candidates: .test) @@ -513,6 +523,20 @@ public struct LineParser: Sendable { candidates.remove(.buildInfo) } + // Source-context echo tracking (state only, no event emitted) + let firstByte = line.utf8.first + let isIndented = firstByte == UInt8(ascii: " ") || firstByte == UInt8(ascii: "\t") + let insideSourceContext = isIndented && sourceContextOpen + if insideSourceContext { + if Self.isCaretLine(line) { sourceContextOpen = false } + } else if !isIndented { + // A `note:` header opens a block too, and no other parser reads those lines. An + // `error:`/`warning:` header sets the flag from its own parse result below, so the + // hot path never scans the same line twice. + sourceContextOpen = + candidates.contains(.diagnosticNote) && isLocatedHeader(line, Self.noteFormatNeedle) + } + if candidates.intersection(.parserCategories).isEmpty { return nil } // Suite name tracking (state only, no event emitted) @@ -544,9 +568,11 @@ public struct LineParser: Sendable { } // Error - if candidates.contains(.error), + if candidates.contains(.error), !insideSourceContext, let error = parseError(line, checkJSON: candidates.contains(.jsonSyntax)) { + // A located diagnostic header opens a source-context block. + if !isIndented, error.line != nil { sourceContextOpen = true } // Fatal error + lastStartedTestName → also emit a synthetic testFailed (matches original) if line.contains("Fatal error"), let testName = lastStartedTestName { lastStartedTestName = nil @@ -565,8 +591,9 @@ public struct LineParser: Sendable { } // Warning - if candidates.contains(.warning) { + if candidates.contains(.warning), !insideSourceContext { if let warning = parseWarning(line, checkJSON: candidates.contains(.jsonSyntax)) { + if !isIndented, warning.line != nil { sourceContextOpen = true } return .warning(warning) } if let warning = parseRuntimeWarning(line) { return .warning(warning) } @@ -999,6 +1026,7 @@ public struct LineParser: Sendable { static let warningFormatNeedle = UTF8Needle(XcodebuildSymbols.warningFormat) static let errorFormatNeedle = UTF8Needle(XcodebuildSymbols.errorFormat) + static let noteFormatNeedle = UTF8Needle(XcodebuildSymbols.noteFormat) static let xctestBundleNeedle = UTF8Needle(".xctest") /// Byte-exact substring search. `String.range(of:)` is Unicode-aware and dominates the @@ -1084,6 +1112,34 @@ public struct LineParser: Sendable { return false } + /// True for a `file:line:col: ` header. The location test rejects tool output such as + /// `swiftgen: error: …`, which never echoes source. + private func isLocatedHeader(_ line: String, _ needle: UTF8Needle) -> Bool { + guard let range = Self.range(of: needle, in: line) else { return false } + return parseLocation(line[.. Bool { + guard let last = line.utf8.last, + last == UInt8(ascii: "^") || last == UInt8(ascii: "~") + else { return false } + + var sawCaret = false + for byte in line.utf8 { + switch byte { + case UInt8(ascii: " "), UInt8(ascii: "\t"), UInt8(ascii: "~"): + continue + case UInt8(ascii: "^"): + sawCaret = true + default: + return false + } + } + return sawCaret + } + private func parseError(_ line: String, checkJSON: Bool) -> BuildError? { if checkJSON && isJSONLikeLine(line) { return nil } if isRuntimeLogNoise(line) { return nil } diff --git a/Sources/XCSiftCore/XcodebuildSymbols.swift b/Sources/XCSiftCore/XcodebuildSymbols.swift index 1f477f9..dce9f6c 100644 --- a/Sources/XCSiftCore/XcodebuildSymbols.swift +++ b/Sources/XCSiftCore/XcodebuildSymbols.swift @@ -6,6 +6,7 @@ enum XcodebuildSymbols { // Diagnostic format patterns (used in parseError/parseWarning) static let errorFormat = ": error: " static let warningFormat = ": warning: " + static let noteFormat = ": note: " static let fatalErrorFormat = ": Fatal error: " static let fatalErrorSuffix = ": Fatal error" @@ -15,6 +16,7 @@ enum XcodebuildSymbols { // Fast-path filter keywords static let errorKeyword = "error:" static let warningKeyword = "warning:" + static let noteKeyword = "note:" static let fatalErrorKeyword = "Fatal error" static let passedKeyword = "passed" static let failedKeyword = "failed" diff --git a/Tests/XCSiftCoreTests/SourceContextEchoTests.swift b/Tests/XCSiftCoreTests/SourceContextEchoTests.swift new file mode 100644 index 0000000..cc0b22d --- /dev/null +++ b/Tests/XCSiftCoreTests/SourceContextEchoTests.swift @@ -0,0 +1,141 @@ +import XCTest + +import XCSiftCore + +/// Tests for compiler source-context echo (issue #78). +/// +/// A `file:line:col: error:/warning:/note:` header is followed by the offending source line, +/// indented, and a caret line. The echoed source can carry `: error: ` inside a string literal or +/// a comment. Those bytes are not a diagnostic. Indentation alone must not decide this: indented +/// tool output stands on its own and stays reportable. +final class SourceContextEchoTests: XCTestCase { + func testEchoedSourceLineWithErrorTextDoesNotFailTheBuild() { + let parser = OutputParser() + let input = """ + /p/M.swift:8:19: warning: expression took 4ms to type-check (limit: 1ms) + let msg = "upload: error: " + String(1) + String(2) + ^~~~~~~~~~~~~~~~~ + Build complete! + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.summary.errors, 0) + XCTAssertEqual(result.summary.warnings, 1) + } + + func testEchoedSourceLineWithWarningTextIsNotAWarning() { + let parser = OutputParser() + let input = """ + /p/M.swift:8:19: error: cannot find 'bar' in scope + let msg = "upload: warning: " + bar() + ^~~~~~~~~~~~~~~~~~~ + ** BUILD FAILED ** + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.summary.errors, 1) + XCTAssertEqual(result.summary.warnings, 0) + } + + func testEchoedSourceLineUnderNoteHeaderIsNotADiagnostic() { + let parser = OutputParser() + let input = """ + /p/A.swift:36:39: warning: call to main actor-isolated initializer + dateProvider: DateProviding = LiveDateProvider(), + ^ + /p/B.swift:16:8: note: calls from outside the actor context are asynchronous + init() { log("actor: error: none") } + ^ + Build complete! + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.summary.errors, 0) + XCTAssertEqual(result.summary.warnings, 1) + } + + /// Indented tool output is not source context. Dropping it would report a broken build as a + /// successful one. + func testIndentedToolErrorStillFailsTheBuild() { + let parser = OutputParser() + let input = """ + swiftgen: error: template not found + ** BUILD SUCCEEDED ** + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.status, "failed") + XCTAssertEqual(result.summary.errors, 1) + } + + func testIndentedScriptPhaseFailureStillFailsTheBuild() { + let parser = OutputParser() + let input = """ + Command PhaseScriptExecution failed with a nonzero exit code + ** BUILD SUCCEEDED ** + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.status, "failed") + XCTAssertEqual(result.summary.errors, 1) + } + + /// The caret line ends the block, so the next indented line is tool output again. + func testCaretLineClosesTheEchoBlock() { + let parser = OutputParser() + let input = """ + /p/M.swift:1:1: warning: expression took 4ms to type-check (limit: 1ms) + let msg = "upload: error: " + String(1) + ^~~~~~~~~~~~~~~~~ + swiftgen: error: template not found + ** BUILD SUCCEEDED ** + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.status, "failed") + XCTAssertEqual(result.summary.errors, 1) + XCTAssertEqual(result.errors[0].message, "template not found") + XCTAssertEqual(result.summary.warnings, 1) + } + + func testDiagnosticAfterAnEchoBlockIsStillParsed() { + let parser = OutputParser() + let input = """ + /p/M.swift:1:1: warning: expression took 4ms to type-check (limit: 1ms) + let msg = "upload: error: " + String(1) + ^~~~~~~~~~~~~~~~~ + /p/N.swift:9:5: error: cannot find 'bar' in scope + ** BUILD FAILED ** + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.summary.errors, 1) + XCTAssertEqual(result.errors[0].file, "/p/N.swift") + XCTAssertEqual(result.errors[0].line, 9) + XCTAssertEqual(result.summary.warnings, 1) + } + + /// Tool output such as `swiftgen: error: …` carries no `:line:` location, so it must not open + /// a block and hide the indented line that follows it. + func testToolErrorDoesNotOpenAnEchoBlock() { + let parser = OutputParser() + let input = """ + swiftgen: error: template not found + sourcery: error: could not parse the model + ** BUILD SUCCEEDED ** + """ + + let result = parser.parse(input: input) + + XCTAssertEqual(result.summary.errors, 2) + } +}