From 0d3e8db95888311fdeaf779244228bb160582d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:34:25 -0300 Subject: [PATCH 01/20] fix: carry compiler output in BuildError.compilationFailed --- .../Build/BuildError.swift | 10 +++++++++- .../Build/BuildStage.swift | 18 +++++++++--------- .../SwiftMutationTesting.swift | 4 ++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftMutationTesting/Build/BuildError.swift b/Sources/SwiftMutationTesting/Build/BuildError.swift index 2c2f3fc..a99f0d8 100644 --- a/Sources/SwiftMutationTesting/Build/BuildError.swift +++ b/Sources/SwiftMutationTesting/Build/BuildError.swift @@ -1,4 +1,12 @@ enum BuildError: Error, Equatable { - case compilationFailed + case compilationFailed(output: String) case xctestrunNotFound + + static func == (lhs: BuildError, rhs: BuildError) -> Bool { + switch (lhs, rhs) { + case (.compilationFailed, .compilationFailed): return true + case (.xctestrunNotFound, .xctestrunNotFound): return true + default: return false + } + } } diff --git a/Sources/SwiftMutationTesting/Build/BuildStage.swift b/Sources/SwiftMutationTesting/Build/BuildStage.swift index 62f027f..6cf24c9 100644 --- a/Sources/SwiftMutationTesting/Build/BuildStage.swift +++ b/Sources/SwiftMutationTesting/Build/BuildStage.swift @@ -24,15 +24,17 @@ struct BuildStage: Sendable { arguments += ["-project", projectURL.path] } - let exitCode = try await launcher.launch( + let (exitCode, buildOutput) = try await launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, + environment: nil, + additionalEnvironment: [:], workingDirectoryURL: sandbox.rootURL, timeout: timeout ) guard exitCode == 0 else { - throw BuildError.compilationFailed + throw BuildError.compilationFailed(output: buildOutput) } let productsURL = derivedDataURL.appendingPathComponent("Build/Products") @@ -59,20 +61,18 @@ struct BuildStage: Sendable { testTarget: String?, timeout: Double ) async throws -> BuildArtifact { - var arguments = ["build", "--build-tests"] + let arguments = ["build", "--build-tests"] - if let testTarget { - arguments += ["--target", testTarget] - } - - let exitCode = try await launcher.launch( + let (exitCode, buildOutput) = try await launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, + environment: nil, + additionalEnvironment: [:], workingDirectoryURL: sandbox.rootURL, timeout: timeout ) - guard exitCode == 0 else { throw BuildError.compilationFailed } + guard exitCode == 0 else { throw BuildError.compilationFailed(output: buildOutput) } return BuildArtifact( derivedDataPath: sandbox.rootURL.appendingPathComponent(".build").path, diff --git a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift index f8031a3..1e9d3d1 100644 --- a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift +++ b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift @@ -12,6 +12,10 @@ public struct SwiftMutationTesting { } catch let error as UsageError { fputs(error.message + "\n", stderr) return .error + } catch BuildError.compilationFailed(let output) { + if !output.isEmpty { fputs(output + "\n", stderr) } + fputs("Build failed. The schematized source could not be compiled.\n", stderr) + return .error } catch SimulatorError.deviceNotFound(let dest) { fputs("Simulator not found for destination: \(dest)\n", stderr) return .error From 94d725a23937b1c7462fae4d59bee91790f86d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:34:30 -0300 Subject: [PATCH 02/20] test: update BuildStageTests for compilationFailed output --- .../Unit/Build/BuildStageTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift index 94ef1be..deb4651 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift @@ -42,13 +42,16 @@ struct BuildStageTests { let sandbox = Sandbox(rootURL: projectDir) let stage = BuildStage(launcher: MockProcessLauncher(exitCode: 1)) - await #expect(throws: BuildError.compilationFailed) { + await #expect { try await stage.build( sandbox: sandbox, scheme: "App", destination: "platform=macOS,arch=arm64", timeout: 60 ) + } throws: { error in + guard case BuildError.compilationFailed = error else { return false } + return true } } @@ -171,8 +174,11 @@ struct BuildStageTests { let sandbox = Sandbox(rootURL: projectDir) let stage = BuildStage(launcher: MockProcessLauncher(exitCode: 1)) - await #expect(throws: BuildError.compilationFailed) { + await #expect { try await stage.buildSPM(sandbox: sandbox, testTarget: nil, timeout: 60) + } throws: { error in + guard case BuildError.compilationFailed = error else { return false } + return true } } From a1cc2da93d3594da95204153ed5aa29fcc682fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:34:37 -0300 Subject: [PATCH 03/20] feat: add createClean to SandboxFactory --- .../Sandbox/SandboxFactory.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift b/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift index abe9afb..9ab367f 100644 --- a/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift +++ b/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift @@ -34,6 +34,20 @@ struct SandboxFactory: Sendable { return Sandbox(rootURL: sandboxURL) } + func createClean(projectPath: String) async throws -> Sandbox { + let sandboxURL = try makeSandboxRoot() + let projectURL = URL(fileURLWithPath: projectPath).resolvingSymlinksInPath() + + try populateDirectory( + source: projectURL, + destination: sandboxURL, + schematizedPaths: [:], + mutatedMapping: nil + ) + + return Sandbox(rootURL: sandboxURL) + } + func create( projectPath: String, mutatedFilePath: String, From b481012f6e15dee6e1397cc169636e33852e8026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:34:43 -0300 Subject: [PATCH 04/20] feat: add SPM build path to FallbackExecutor --- .../Execution/FallbackExecutor.swift | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift index 86cf0ce..48a43c1 100644 --- a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift @@ -36,24 +36,36 @@ struct FallbackExecutor: Sendable { await deps.reporter.report(.fallbackBuildStarted(filePath: file.originalPath)) - guard case .xcode(let scheme, let destination) = configuration.build.projectType else { - try? sandbox.cleanup() - return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) - } - let artifact: BuildArtifact - do { - artifact = try await BuildStage(launcher: deps.launcher).build( - sandbox: sandbox, - scheme: scheme, - destination: destination, - timeout: configuration.build.timeout - ) - await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) - } catch { - await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) - try? sandbox.cleanup() - return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + switch configuration.build.projectType { + case .xcode(let scheme, let destination): + do { + artifact = try await BuildStage(launcher: deps.launcher).build( + sandbox: sandbox, + scheme: scheme, + destination: destination, + timeout: configuration.build.timeout + ) + await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) + } catch { + await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) + try? sandbox.cleanup() + return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + } + + case .spm: + do { + artifact = try await BuildStage(launcher: deps.launcher).buildSPM( + sandbox: sandbox, + testTarget: configuration.build.testTarget, + timeout: configuration.build.timeout + ) + await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) + } catch { + await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) + try? sandbox.cleanup() + return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + } } let context = TestExecutionContext( From 0be9f61112867700472d2cd2fb7a265bfd83dcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:34:50 -0300 Subject: [PATCH 05/20] feat: add retryExcludingErrors, canonicalPath and validateSPMBaseline to MutantExecutor --- .../Execution/MutantExecutor.swift | 170 ++++++++++++++++-- 1 file changed, 160 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index ecc8824..c307c30 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -39,20 +39,36 @@ struct MutantExecutor: Sendable { supportFileContent: input.supportFileContent ) - let artifact = try await buildArtifact(sandbox: sandbox, deps: deps) + let (artifact, schemaBuildExcluded) = try await buildArtifact(sandbox: sandbox, input: input, deps: deps) let pool = try await makePool(launcher: launcher) try await pool.setUp() await reporter.report(.simulatorPoolReady(size: pool.size)) - var results: [ExecutionResult] + var results: [ExecutionResult] = [] + + for mutant in schemaBuildExcluded { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + await deps.cacheStore.store(status: .unviable, for: key) + let index = await deps.counter.increment() + await deps.reporter.report( + .mutantFinished(descriptor: mutant, status: .unviable, index: index, total: deps.counter.total)) + results.append(ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0)) + } + + let excludedIDs = Set(schemaBuildExcluded.map(\.id)) + let testableSchematizable = schematizable.filter { !excludedIDs.contains($0.id) } + if let artifact { + if case .spm = configuration.build.projectType { + await validateSPMBaseline(sandbox: sandbox, deps: deps) + } let context = TestExecutionContext( artifact: artifact, sandbox: sandbox, pool: pool, configuration: configuration, testFilesHash: testFilesHash ) - results = try await runNormal(deps: deps, context: context, schematizable: schematizable) - } else { - results = try await runFallback(deps: deps, input: input, pool: pool, testFilesHash: testFilesHash) + results += try await runNormal(deps: deps, context: context, schematizable: testableSchematizable) + } else if !testableSchematizable.isEmpty { + results += try await runFallback(deps: deps, input: input, pool: pool, testFilesHash: testFilesHash) } results += try await runIncompatible( @@ -83,7 +99,11 @@ struct MutantExecutor: Sendable { return results } - private func buildArtifact(sandbox: Sandbox, deps: ExecutionDeps) async throws -> BuildArtifact? { + private func buildArtifact( + sandbox: Sandbox, + input: RunnerInput, + deps: ExecutionDeps + ) async throws -> (BuildArtifact?, [MutantDescriptor]) { await deps.reporter.report(.buildStarted) let start = Date() let stage = BuildStage(launcher: deps.launcher) @@ -98,19 +118,113 @@ struct MutantExecutor: Sendable { timeout: configuration.build.timeout ) await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) - return artifact - } catch BuildError.compilationFailed { - return nil + return (artifact, []) + } catch BuildError.compilationFailed(_) { + return (nil, []) } case .spm: + do { + let artifact = try await stage.buildSPM( + sandbox: sandbox, + testTarget: configuration.build.testTarget, + timeout: configuration.build.timeout + ) + await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) + return (artifact, []) + } catch BuildError.compilationFailed(let output) { + fputs("[xmr] schematized build failed — starting retry\n", stderr) + let (artifact, excluded) = try await retryExcludingErrors( + output: output, + sandbox: sandbox, + input: input, + stage: stage, + deps: deps, + start: start, + alreadyExcluded: [] + ) + fputs("[xmr] retry done: artifact=\(artifact != nil ? "ok" : "nil") excluded=\(excluded.count)\n", stderr) + return (artifact, excluded) + } + } + } + + private func retryExcludingErrors( + output: String, + sandbox: Sandbox, + input: RunnerInput, + stage: BuildStage, + deps: ExecutionDeps, + start: Date, + alreadyExcluded: [MutantDescriptor] + ) async throws -> (BuildArtifact?, [MutantDescriptor]) { + let sandboxRoot = canonicalPath(sandbox.rootURL.path) + let projectRoot = URL(fileURLWithPath: input.projectPath).resolvingSymlinksInPath().path + + fputs("[xmr] sandboxRoot=\(sandboxRoot)\n", stderr) + + let errorSandboxPaths = Set( + output.components(separatedBy: "\n").compactMap { line -> String? in + guard line.hasPrefix(sandboxRoot) else { return nil } + let path = line.components(separatedBy: ":").first ?? "" + return path.hasSuffix(".swift") ? path : nil + } + ) + + let alreadyExcludedIDs = Set(alreadyExcluded.map(\.id)) + + var newlyExcluded: [MutantDescriptor] = [] + + for sandboxPath in errorSandboxPaths { + let relative = String(sandboxPath.dropFirst(sandboxRoot.count)) + let originalPath = projectRoot + relative + + guard FileManager.default.fileExists(atPath: originalPath) else { continue } + + let mutantsInFile = input.mutants.filter { m in + m.isSchematizable + && !alreadyExcludedIDs.contains(m.id) + && URL(fileURLWithPath: m.filePath).resolvingSymlinksInPath().path == originalPath + } + + guard !mutantsInFile.isEmpty else { continue } + + try? FileManager.default.removeItem(atPath: sandboxPath) + try? FileManager.default.createSymbolicLink( + atPath: sandboxPath, + withDestinationPath: originalPath + ) + + newlyExcluded += mutantsInFile + } + + fputs("[xmr] error files=\(errorSandboxPaths.count) newly excluded=\(newlyExcluded.count)\n", stderr) + + guard !newlyExcluded.isEmpty else { + fputs("[xmr] no files matched sandboxRoot — skipping retry\n", stderr) + return (nil, alreadyExcluded) + } + + let allExcluded = alreadyExcluded + newlyExcluded + + do { let artifact = try await stage.buildSPM( sandbox: sandbox, testTarget: configuration.build.testTarget, timeout: configuration.build.timeout ) await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) - return artifact + return (artifact, allExcluded) + } catch BuildError.compilationFailed(let newOutput) { + return try await retryExcludingErrors( + output: newOutput, + sandbox: sandbox, + input: input, + stage: stage, + deps: deps, + start: start, + alreadyExcluded: allExcluded + ) } } @@ -142,6 +256,42 @@ struct MutantExecutor: Sendable { .execute(mutants, configuration: configuration, pool: pool, testFilesHash: testFilesHash) } + private func validateSPMBaseline(sandbox: Sandbox, deps: ExecutionDeps) async { + var arguments = ["test", "--skip-build"] + if let testTarget = configuration.build.testTarget { + arguments += ["--filter", testTarget] + } + + guard let result = try? await deps.launcher.launchCapturing( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: arguments, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) else { + fputs("[xmr] baseline check failed to launch\n", stderr) + return + } + + if result.exitCode == 0 { + fputs("[xmr] baseline passed — schematized binary is healthy\n", stderr) + } else { + let lines = result.output.components(separatedBy: "\n") + let failLines = lines.filter { $0.contains("failed") || $0.contains("Issue") || $0.contains("✗") || $0.contains("error:") || $0.contains("FAILED") } + let snippet = failLines.prefix(20).joined(separator: "↵") + fputs("[xmr] baseline FAILED exitCode=\(result.exitCode) failures=\(snippet)\n", stderr) + } + } + + private func canonicalPath(_ path: String) -> String { + path.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) + } + } + private func makePool(launcher: any ProcessLaunching) async throws -> SimulatorPool { let destination: String if case .xcode(_, let dest) = configuration.build.projectType { From 7e6b58aa210a2cd8006563331f8271de619242bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:34:57 -0300 Subject: [PATCH 06/20] test: add spmRetryExcludingErrors tests and update spmBuildFailure in MutantExecutorTests --- .../TestSupport/IOSSimulatorMock.swift | 1 + .../Unit/Execution/MutantExecutorTests.swift | 115 +++++++++++++++--- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift index 4a6571c..d7993bf 100644 --- a/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift +++ b/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift @@ -17,6 +17,7 @@ struct IOSSimulatorMock: ProcessLaunching { additionalEnvironment: [String: String], workingDirectoryURL: URL, timeout: Double ) async throws -> (exitCode: Int32, output: String) { if arguments.contains("clone") { return (0, cloneUDID + "\n") } + if executableURL.lastPathComponent == "xcodebuild" { return (1, "") } return (0, listJSON) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index e0f7b70..9e2b7b2 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -3,8 +3,41 @@ import Testing @testable import SwiftMutationTesting +private actor SPMRetryExcludingErrorsMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> (exitCode: Int32, output: String) { + guard arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + if buildCallCount == 1 { + let fooPath = workingDirectoryURL.appendingPathComponent("Foo.swift").path + let canonical = fooPath.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return fooPath } + defer { free(resolved) } + return String(cString: resolved) + } + return (1, "\(canonical):1:5: error: cannot convert value") + } + return (0, "") + } +} + private actor FallbackBuildSucceedingMock: ProcessLaunching { - private var launchCount = 0 + private var captureCount = 0 func launch( executableURL: URL, @@ -12,17 +45,7 @@ private actor FallbackBuildSucceedingMock: ProcessLaunching { workingDirectoryURL: URL, timeout: Double ) async throws -> Int32 { - launchCount += 1 - if launchCount == 1 { return 1 } - if let idx = arguments.firstIndex(of: "-derivedDataPath"), idx + 1 < arguments.count { - let productsURL = URL(fileURLWithPath: arguments[idx + 1]) - .appendingPathComponent("Build/Products") - try? FileManager.default.createDirectory(at: productsURL, withIntermediateDirectories: true) - let plist: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] - let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - try? data?.write(to: productsURL.appendingPathComponent("fake.xctestrun")) - } - return 0 + 0 } func launchCapturing( @@ -33,7 +56,17 @@ private actor FallbackBuildSucceedingMock: ProcessLaunching { workingDirectoryURL: URL, timeout: Double ) async throws -> (exitCode: Int32, output: String) { - (0, "") + captureCount += 1 + if captureCount == 1 { return (1, "") } + if let idx = arguments.firstIndex(of: "-derivedDataPath"), idx + 1 < arguments.count { + let productsURL = URL(fileURLWithPath: arguments[idx + 1]) + .appendingPathComponent("Build/Products") + try? FileManager.default.createDirectory(at: productsURL, withIntermediateDirectories: true) + let plist: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] + let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try? data?.write(to: productsURL.appendingPathComponent("fake.xctestrun")) + } + return (0, "") } } @@ -248,21 +281,63 @@ struct MutantExecutorTests { #expect(results[0].status == .survived) } - @Test("Given SPM project type and build failure, when execute called, then throws compilationFailed") - func spmBuildFailureThrows() async throws { + @Test("Given SPM project type and build failure, when execute called, then mutant is marked unviable") + func spmBuildFailureMarksSchematizableMutantsUnviable() async throws { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + let executor = MutantExecutor( configuration: makeConfigurationSPM(projectPath: dir.path), launcher: MockProcessLauncher(exitCode: 1) ) - let mutant = makeMutant(id: "m0", filePath: "/tmp/Foo.swift", isSchematizable: true) - let input = makeInputSPM(projectPath: dir.path, mutants: [mutant]) + let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], + mutants: [mutant] + ) - await #expect(throws: BuildError.compilationFailed) { - try await executor.execute(input) - } + let results = try await executor.execute(input) + + #expect(results.count == 1) + #expect(results[0].status == .unviable) + } + + @Test("Given SPM build fails with canonical /private/var path in error, when retry succeeds, then only failing file is unviable") + func spmRetryExcludingErrorsMatchesCanonicalPaths() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + let barFile = dir.appendingPathComponent("Bar.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + try "let y = true".write(to: barFile, atomically: true, encoding: .utf8) + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMRetryExcludingErrorsMock() + ) + let mutantFoo = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) + let mutantBar = makeMutant(id: "m1", filePath: barFile.path, isSchematizable: true, mutatedContent: "let y = false") + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [ + SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false"), + SchematizedFile(originalPath: barFile.path, schematizedContent: "let y = false"), + ], + mutants: [mutantFoo, mutantBar] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 2) + let fooResult = results.first { $0.descriptor.id == "m0" } + let barResult = results.first { $0.descriptor.id == "m1" } + #expect(fooResult?.status == .unviable) + #expect(barResult?.status == .survived) } private func makeConfiguration( From d16a6fbda7a1f78ec0358a07bd7e527eab59e597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:35:08 -0300 Subject: [PATCH 07/20] feat: use shared sandbox with incremental builds for SPM in IncompatibleMutantExecutor --- .../IncompatibleMutantExecutor.swift | 140 +++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index c05f097..2949b86 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -11,6 +11,7 @@ struct IncompatibleMutantExecutor: Sendable { testFilesHash: String ) async throws -> [ExecutionResult] { var results: [ExecutionResult] = [] + var pending: [MutantDescriptor] = [] for mutant in mutants { let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) @@ -24,13 +25,150 @@ struct IncompatibleMutantExecutor: Sendable { continue } - let result = try await run(mutant: mutant, key: key, configuration: configuration, pool: pool) + pending.append(mutant) + } + + if case .spm = configuration.build.projectType { + results += try await runSPMShared( + mutants: pending, configuration: configuration, testFilesHash: testFilesHash) + } else { + for mutant in pending { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + results.append(try await run(mutant: mutant, key: key, configuration: configuration, pool: pool)) + } + } + + return results + } + + private func runSPMShared( + mutants: [MutantDescriptor], + configuration: RunnerConfiguration, + testFilesHash: String + ) async throws -> [ExecutionResult] { + var results: [ExecutionResult] = [] + + let viable = mutants.filter { $0.mutatedSourceContent != nil } + + for mutant in mutants where mutant.mutatedSourceContent == nil { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + results.append(await storeAndReport(mutant: mutant, key: key, sandbox: nil)) + } + + guard !viable.isEmpty else { return results } + + let sandbox = try await sandboxFactory.createClean(projectPath: configuration.projectPath) + + let initialBuild = try await deps.launcher.launchCapturing( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: spmBuildArguments(configuration: configuration), + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) + + guard initialBuild.exitCode == 0 else { + for mutant in viable { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + results.append(await storeAndReport(mutant: mutant, key: key, sandbox: nil)) + } + try? sandbox.cleanup() + return results + } + + let projectRoot = URL(fileURLWithPath: configuration.projectPath).resolvingSymlinksInPath().path + let sandboxRoot = sandbox.rootURL.resolvingSymlinksInPath().path + + for mutant in viable { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let result = try await runInSharedSandbox( + mutant: mutant, + key: key, + configuration: configuration, + sandbox: sandbox, + projectRoot: projectRoot, + sandboxRoot: sandboxRoot + ) results.append(result) } + try? sandbox.cleanup() return results } + private func spmBuildArguments(configuration: RunnerConfiguration) -> [String] { + ["build", "--build-tests"] + } + + private func runInSharedSandbox( + mutant: MutantDescriptor, + key: MutantCacheKey, + configuration: RunnerConfiguration, + sandbox: Sandbox, + projectRoot: String, + sandboxRoot: String + ) async throws -> ExecutionResult { + let originalCanonical = URL(fileURLWithPath: mutant.filePath).resolvingSymlinksInPath().path + + guard originalCanonical.hasPrefix(projectRoot), let content = mutant.mutatedSourceContent else { + return await storeAndReport(mutant: mutant, key: key, sandbox: nil) + } + + let relative = String(originalCanonical.dropFirst(projectRoot.count)) + let sandboxFilePath = sandboxRoot + relative + + do { + try content.write(toFile: sandboxFilePath, atomically: true, encoding: .utf8) + } catch { + return await storeAndReport(mutant: mutant, key: key, sandbox: nil) + } + + defer { + try? FileManager.default.removeItem(atPath: sandboxFilePath) + try? FileManager.default.createSymbolicLink(atPath: sandboxFilePath, withDestinationPath: originalCanonical) + } + + let build = try await deps.launcher.launchCapturing( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: spmBuildArguments(configuration: configuration), + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) + + guard build.exitCode == 0 else { + return await storeAndReport(mutant: mutant, key: key, sandbox: nil) + } + + var testArgs = ["test", "--skip-build"] + if let testTarget = configuration.build.testTarget { + testArgs += ["--filter", testTarget] + } + + let start = Date() + let test = try await deps.launcher.launchCapturing( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: testArgs, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) + let duration = Date().timeIntervalSince(start) + + let outcome = SPMResultParser().parse(exitCode: test.exitCode, output: test.output) + let status = outcome.asExecutionStatus + + let index = await deps.counter.increment() + await deps.reporter.report( + .mutantFinished(descriptor: mutant, status: status, index: index, total: deps.counter.total)) + await deps.cacheStore.store(status: status, for: key) + + return ExecutionResult(descriptor: mutant, status: status, testDuration: duration) + } + private func run( mutant: MutantDescriptor, key: MutantCacheKey, From 7ef507dc7936032aff10d6f0a89743e88e9fecaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:35:14 -0300 Subject: [PATCH 08/20] test: update IncompatibleMutantExecutorTests for shared SPM sandbox --- .../IncompatibleMutantExecutorTests.swift | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift index 3ec79b8..88a19bf 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift @@ -3,6 +3,33 @@ import Testing @testable import SwiftMutationTesting +private actor SPMBuildSuccessTestFailureMock: ProcessLaunching { + private let failureOutput: String + + init(failureOutput: String) { + self.failureOutput = failureOutput + } + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> (exitCode: Int32, output: String) { + if arguments.first == "test" { return (1, failureOutput) } + return (0, "") + } +} + @Suite("IncompatibleMutantExecutor") struct IncompatibleMutantExecutorTests { @Test("Given 3 mutants with content, when execute called, then 3 results are returned in order") @@ -235,12 +262,15 @@ struct IncompatibleMutantExecutorTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let executor = makeExecutorSPM(in: dir, exitCode: 0) + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let executor = makeExecutorSPM(in: dir, launcher: MockProcessLauncher(exitCode: 0)) let pool = makePool() try await pool.setUp() let results = try await executor.execute( - [makeMutant(id: "m0", content: "let x = 1")], + [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = 1")], configuration: makeConfigurationSPM(projectPath: dir.path), pool: pool, testFilesHash: "hash" @@ -254,13 +284,17 @@ struct IncompatibleMutantExecutorTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + let output = #"Test "myTest" failed after 0.001 seconds."# - let executor = makeExecutorSPM(in: dir, exitCode: 1, output: output) + let executor = makeExecutorSPM( + in: dir, launcher: SPMBuildSuccessTestFailureMock(failureOutput: output)) let pool = makePool() try await pool.setUp() let results = try await executor.execute( - [makeMutant(id: "m0", content: "let x = 1")], + [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = 1")], configuration: makeConfigurationSPM(projectPath: dir.path), pool: pool, testFilesHash: "hash" @@ -271,12 +305,11 @@ struct IncompatibleMutantExecutorTests { private func makeExecutorSPM( in dir: URL, - exitCode: Int32, - output: String = "" + launcher: any ProcessLaunching ) -> IncompatibleMutantExecutor { IncompatibleMutantExecutor( deps: ExecutionDeps( - launcher: MockProcessLauncher(exitCode: exitCode, output: output), + launcher: launcher, cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), reporter: MockProgressReporter(), counter: MutationCounter(total: 1) @@ -324,10 +357,10 @@ struct IncompatibleMutantExecutorTests { ) } - private func makeMutant(id: String, content: String?) -> MutantDescriptor { + private func makeMutant(id: String, filePath: String = "/tmp/Foo.swift", content: String?) -> MutantDescriptor { MutantDescriptor( id: id, - filePath: "/tmp/Foo.swift", + filePath: filePath, line: 1, column: 1, utf8Offset: 0, From 5a47f94d4cf60d6123e03ae71c3da8ac5a6fc18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 15:35:20 -0300 Subject: [PATCH 09/20] fix: log unviable mutant diagnostics in TestExecutionStage --- .../SwiftMutationTesting/Execution/TestExecutionStage.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 1374205..9338dc2 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -93,6 +93,10 @@ struct TestExecutionStage: Sendable { let outcome = SPMResultParser().parse(exitCode: launched.exitCode, output: launched.output) let status = outcome.asExecutionStatus + if status == .unviable { + let snippet = String(launched.output.prefix(300)).replacingOccurrences(of: "\n", with: "↵") + fputs("[xmr] unviable mutant=\(mutant.id) file=\(URL(fileURLWithPath: mutant.filePath).lastPathComponent) exitCode=\(launched.exitCode) output=\(snippet)\n", stderr) + } let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) await deps.cacheStore.store(status: status, for: key) let index = await deps.counter.increment() From 3b363a6bcfc56349ca0e6d227792347479692965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 16:36:49 -0300 Subject: [PATCH 10/20] fix: kill process group after normal process exit in ProcessLauncher --- .../SwiftMutationTesting/Infrastructure/ProcessLauncher.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift index b0c97b6..2b3911c 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift @@ -88,6 +88,7 @@ struct ProcessLauncher: Sendable, ProcessLaunching { process.terminationHandler = { proc in timeoutTask.cancel() + kill(-proc.processIdentifier, SIGKILL) let exitCode: Int32 = killedByUs.value ? -1 : proc.terminationStatus continuation.resume(returning: exitCode) } @@ -116,6 +117,7 @@ struct ProcessLauncher: Sendable, ProcessLaunching { process.terminationHandler = { terminated in timeoutTask.cancel() + kill(-terminated.processIdentifier, SIGKILL) capture.fileHandle.closeFile() let output = (try? String(contentsOf: capture.tempURL, encoding: .utf8)) ?? "" try? FileManager.default.removeItem(at: capture.tempURL) From e9d1aa76e27a12bca9690f01c76bac86375c8cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 16:36:55 -0300 Subject: [PATCH 11/20] feat: narrow exclusion per problematic mutant on schematized build failure --- .../Execution/MutantExecutor.swift | 100 ++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index c307c30..bf3cd78 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -189,13 +189,12 @@ struct MutantExecutor: Sendable { guard !mutantsInFile.isEmpty else { continue } - try? FileManager.default.removeItem(atPath: sandboxPath) - try? FileManager.default.createSymbolicLink( - atPath: sandboxPath, - withDestinationPath: originalPath + newlyExcluded += excludeProblematicMutants( + sandboxPath: sandboxPath, + originalPath: originalPath, + errorOutput: output, + mutantsInFile: mutantsInFile ) - - newlyExcluded += mutantsInFile } fputs("[xmr] error files=\(errorSandboxPaths.count) newly excluded=\(newlyExcluded.count)\n", stderr) @@ -284,6 +283,95 @@ struct MutantExecutor: Sendable { } } + private func excludeProblematicMutants( + sandboxPath: String, + originalPath: String, + errorOutput: String, + mutantsInFile: [MutantDescriptor] + ) -> [MutantDescriptor] { + let errorLines = Set( + errorOutput.components(separatedBy: "\n").compactMap { line -> Int? in + guard line.hasPrefix(sandboxPath + ":") else { return nil } + let remainder = String(line.dropFirst(sandboxPath.count + 1)) + return remainder.components(separatedBy: ":").first.flatMap { Int($0) } + } + ) + + guard + !errorLines.isEmpty, + let content = try? String(contentsOfFile: sandboxPath, encoding: .utf8) + else { + restoreOriginal(sandboxPath: sandboxPath, originalPath: originalPath) + return mutantsInFile + } + + let lines = content.components(separatedBy: "\n") + let mutantIDs = Set(mutantsInFile.map(\.id)) + var problematicIDs = Set() + + for errorLine in errorLines { + let lineIndex = errorLine - 1 + guard lineIndex >= 0, lineIndex < lines.count else { continue } + var i = lineIndex + while i >= 0 { + let trimmed = lines[i].trimmingCharacters(in: .whitespaces) + if let id = mutantCaseID(from: trimmed), mutantIDs.contains(id) { + problematicIDs.insert(id) + break + } + if trimmed == "default:" || trimmed.hasPrefix("switch ") { break } + i -= 1 + } + } + + guard !problematicIDs.isEmpty else { + restoreOriginal(sandboxPath: sandboxPath, originalPath: originalPath) + return mutantsInFile + } + + let narrowed = removingCases(problematicIDs, from: lines) + try? narrowed.write(toFile: sandboxPath, atomically: true, encoding: .utf8) + + let excluded = mutantsInFile.filter { problematicIDs.contains($0.id) } + fputs("[xmr] narrow exclusion: file=\(URL(fileURLWithPath: originalPath).lastPathComponent) total=\(mutantsInFile.count) excluded=\(excluded.count) remaining=\(mutantsInFile.count - excluded.count)\n", stderr) + return excluded + } + + private func restoreOriginal(sandboxPath: String, originalPath: String) { + try? FileManager.default.removeItem(atPath: sandboxPath) + try? FileManager.default.createSymbolicLink(atPath: sandboxPath, withDestinationPath: originalPath) + } + + private func mutantCaseID(from trimmedLine: String) -> String? { + let casePrefix = "case \"" + let caseSuffix = "\":" + guard trimmedLine.hasPrefix(casePrefix), trimmedLine.hasSuffix(caseSuffix) else { return nil } + let id = String(trimmedLine.dropFirst(casePrefix.count).dropLast(caseSuffix.count)) + return id.hasPrefix("swift-mutation-testing_") ? id : nil + } + + private func removingCases(_ ids: Set, from lines: [String]) -> String { + var result: [String] = [] + var skipping = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if let id = mutantCaseID(from: trimmed) { + skipping = ids.contains(id) + if !skipping { result.append(line) } + } else if skipping { + if trimmed == "default:" || mutantCaseID(from: trimmed) != nil { + skipping = false + result.append(line) + } + } else { + result.append(line) + } + } + + return result.joined(separator: "\n") + } + private func canonicalPath(_ path: String) -> String { path.withCString { ptr in guard let resolved = realpath(ptr, nil) else { return path } From 9ec7109532885223b5ba590288f35a7ce933ab07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Wed, 1 Apr 2026 16:36:59 -0300 Subject: [PATCH 12/20] test: add narrow exclusion test for per-mutant schematized build error --- .../Unit/Execution/MutantExecutorTests.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index 9e2b7b2..2e22a28 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -36,6 +36,39 @@ private actor SPMRetryExcludingErrorsMock: ProcessLaunching { } } +private actor SPMNarrowExclusionMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> (exitCode: Int32, output: String) { + guard arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + if buildCallCount == 1 { + let fooPath = workingDirectoryURL.appendingPathComponent("Foo.swift").path + let canonical = fooPath.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return fooPath } + defer { free(resolved) } + return String(cString: resolved) + } + return (1, "\(canonical):4:1: error: type mismatch") + } + return (0, "") + } +} + private actor FallbackBuildSucceedingMock: ProcessLaunching { private var captureCount = 0 @@ -340,6 +373,48 @@ struct MutantExecutorTests { #expect(barResult?.status == .survived) } + @Test("Given SPM build error on line inside first case block, when retry, then only that mutant is excluded") + func spmNarrowExclusionOnSpecificCase() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let original = true".write(to: fooFile, atomically: true, encoding: .utf8) + + // Line 4 is the body of case "swift-mutation-testing_0"; line 6 is case "swift-mutation-testing_1" + let schematized = + "func foo() {\n" + + "switch __swiftMutationTestingID {\n" + + "case \"swift-mutation-testing_0\":\n" + + "return true\n" + + "case \"swift-mutation-testing_1\":\n" + + "return false\n" + + "default:\n" + + "return nil\n" + + "}\n" + + "}" + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMNarrowExclusionMock() + ) + let m0 = makeMutant(id: "swift-mutation-testing_0", filePath: fooFile.path, isSchematizable: true) + let m1 = makeMutant(id: "swift-mutation-testing_1", filePath: fooFile.path, isSchematizable: true, mutatedContent: "let y = false") + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: schematized)], + mutants: [m0, m1] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 2) + let r0 = results.first { $0.descriptor.id == "swift-mutation-testing_0" } + let r1 = results.first { $0.descriptor.id == "swift-mutation-testing_1" } + #expect(r0?.status == .unviable) + #expect(r1?.status == .survived) + } + private func makeConfiguration( projectPath: String, noCache: Bool = false, From 3fc328a2b6ed28620204ca60a39a070a8f2343be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:06:49 -0300 Subject: [PATCH 13/20] fix: classify empty output as crash in SPMResultParser --- .../Execution/Parsing/SPMResultParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftMutationTesting/Execution/Parsing/SPMResultParser.swift b/Sources/SwiftMutationTesting/Execution/Parsing/SPMResultParser.swift index 04c98cf..bad4f74 100644 --- a/Sources/SwiftMutationTesting/Execution/Parsing/SPMResultParser.swift +++ b/Sources/SwiftMutationTesting/Execution/Parsing/SPMResultParser.swift @@ -6,7 +6,7 @@ struct SPMResultParser: Sendable { switch TestOutputParser().parse(output) { case .killed(let name): return .testsFailed(failingTest: name) case .crashed: return .crashed - case .unviable: return .unviable + case .unviable: return output.isEmpty ? .crashed : .unviable } } } From fcca6ffd93c3a9ba5cc3c84e25c1cc1754cfdb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:06:58 -0300 Subject: [PATCH 14/20] feat: add CapturedOutput and launchCapturingDeferred to ProcessLaunching --- .../Infrastructure/ProcessLaunching.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift index 042634c..14e8983 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift @@ -1,5 +1,11 @@ import Foundation +struct CapturedOutput: Sendable { + let exitCode: Int32 + let output: String + let cleanup: @Sendable () -> Void +} + protocol ProcessLaunching: Sendable { func launch( executableURL: URL, @@ -16,4 +22,34 @@ protocol ProcessLaunching: Sendable { workingDirectoryURL: URL, timeout: Double ) async throws -> (exitCode: Int32, output: String) + + func launchCapturingDeferred( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> CapturedOutput +} + +extension ProcessLaunching { + func launchCapturingDeferred( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> CapturedOutput { + let result = try await launchCapturing( + executableURL: executableURL, + arguments: arguments, + environment: environment, + additionalEnvironment: additionalEnvironment, + workingDirectoryURL: workingDirectoryURL, + timeout: timeout + ) + return CapturedOutput(exitCode: result.exitCode, output: result.output, cleanup: {}) + } } From 0f7df87cd62d9678edf374501d184152e78bad1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:07:08 -0300 Subject: [PATCH 15/20] feat: implement launchCapturingDeferred and escaped child cleanup in ProcessLauncher --- .../Infrastructure/ProcessLauncher.swift | 142 +++++++++++++++++- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift index 2b3911c..41321f9 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift @@ -15,14 +15,15 @@ struct ProcessLauncher: Sendable, ProcessLaunching { process.standardError = FileHandle.nullDevice let killedByUs = KilledByUsFlag() + let sandboxPath = workingDirectoryURL.path return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in - self.startProcess(process, killedByUs: killedByUs, timeout: timeout, continuation: continuation) + self.startProcess(process, killedByUs: killedByUs, timeout: timeout, sandboxPath: sandboxPath, continuation: continuation) } } onCancel: { killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) + terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) } } @@ -59,18 +60,20 @@ struct ProcessLauncher: Sendable, ProcessLaunching { process.standardError = fileHandle let killedByUs = KilledByUsFlag() + let sandboxPath = workingDirectoryURL.path return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in self.startCapturingProcess( process, killedByUs: killedByUs, timeout: timeout, + sandboxPath: sandboxPath, capture: CaptureTarget(fileHandle: fileHandle, tempURL: tempURL), continuation: continuation ) } } onCancel: { killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) + terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) } } @@ -78,17 +81,19 @@ struct ProcessLauncher: Sendable, ProcessLaunching { _ process: Process, killedByUs: KilledByUsFlag, timeout: Double, + sandboxPath: String, continuation: CheckedContinuation ) { let timeoutTask = Task { try await Task.sleep(for: .seconds(timeout)) killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) + terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) } process.terminationHandler = { proc in timeoutTask.cancel() kill(-proc.processIdentifier, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) let exitCode: Int32 = killedByUs.value ? -1 : proc.terminationStatus continuation.resume(returning: exitCode) } @@ -102,22 +107,115 @@ struct ProcessLauncher: Sendable, ProcessLaunching { } } + func launchCapturingDeferred( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> CapturedOutput { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.currentDirectoryURL = workingDirectoryURL + + if let environment { + process.environment = environment + } + + if !additionalEnvironment.isEmpty { + var env = process.environment ?? ProcessInfo.processInfo.environment + for (key, value) in additionalEnvironment { + env[key] = value + } + process.environment = env + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + FileManager.default.createFile(atPath: tempURL.path, contents: nil) + let fileHandle = try FileHandle(forWritingTo: tempURL) + process.standardOutput = fileHandle + process.standardError = fileHandle + + let killedByUs = KilledByUsFlag() + let sandboxPath = workingDirectoryURL.path + + let result = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.startDeferredCapturingProcess( + process, killedByUs: killedByUs, timeout: timeout, + sandboxPath: sandboxPath, + capture: CaptureTarget(fileHandle: fileHandle, tempURL: tempURL), + continuation: continuation + ) + } + } onCancel: { + killedByUs.mark() + terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) + } + + return result + } + + private func startDeferredCapturingProcess( + _ process: Process, + killedByUs: KilledByUsFlag, + timeout: Double, + sandboxPath: String, + capture: CaptureTarget, + continuation: CheckedContinuation + ) { + let timeoutTask = Task { + try await Task.sleep(for: .seconds(timeout)) + killedByUs.mark() + terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) + } + + process.terminationHandler = { terminated in + timeoutTask.cancel() + capture.fileHandle.closeFile() + let output = (try? String(contentsOf: capture.tempURL, encoding: .utf8)) ?? "" + try? FileManager.default.removeItem(at: capture.tempURL) + let exitCode: Int32 = killedByUs.value ? -1 : terminated.terminationStatus + let pid = terminated.processIdentifier + let cleanup: @Sendable () -> Void = { + kill(-pid, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) + } + continuation.resume(returning: CapturedOutput(exitCode: exitCode, output: output, cleanup: cleanup)) + } + + do { + try process.run() + setpgid(process.processIdentifier, process.processIdentifier) + } catch { + timeoutTask.cancel() + capture.fileHandle.closeFile() + try? FileManager.default.removeItem(at: capture.tempURL) + continuation.resume(throwing: error) + } + } + private func startCapturingProcess( _ process: Process, killedByUs: KilledByUsFlag, timeout: Double, + sandboxPath: String, capture: CaptureTarget, continuation: CheckedContinuation<(exitCode: Int32, output: String), any Error> ) { let timeoutTask = Task { try await Task.sleep(for: .seconds(timeout)) killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) + terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) } process.terminationHandler = { terminated in timeoutTask.cancel() kill(-terminated.processIdentifier, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) capture.fileHandle.closeFile() let output = (try? String(contentsOf: capture.tempURL, encoding: .utf8)) ?? "" try? FileManager.default.removeItem(at: capture.tempURL) @@ -136,12 +234,44 @@ struct ProcessLauncher: Sendable, ProcessLaunching { } } - private func terminateProcessGroup(pid: Int32) { + private func terminateProcessGroup(pid: Int32, sandboxPath: String = "") { guard pid > 0 else { return } kill(-pid, SIGTERM) Task { try? await Task.sleep(for: .seconds(5)) kill(-pid, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) + } + } + + private func killEscapedChildren(sandboxPath: String) { + let sandboxName = URL(fileURLWithPath: sandboxPath).lastPathComponent + guard sandboxName.hasPrefix("xmr-") else { return } + guard let pathData = sandboxName.data(using: .utf8) else { return } + + var size = 0 + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return } + + let procSize = MemoryLayout.stride + var procs = [kinfo_proc](repeating: kinfo_proc(), count: size / procSize) + guard sysctl(&mib, 4, &procs, &size, nil, 0) == 0 else { return } + + for i in 0..<(size / procSize) { + let pid = procs[i].kp_proc.p_pid + guard pid > 1 else { continue } + + var argSize = 0 + var argMib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + guard sysctl(&argMib, 3, nil, &argSize, nil, 0) == 0, argSize > 0 else { continue } + + var argBuf = [UInt8](repeating: 0, count: argSize) + guard sysctl(&argMib, 3, &argBuf, &argSize, nil, 0) == 0 else { continue } + + if Data(argBuf[.. Date: Fri, 3 Apr 2026 15:07:16 -0300 Subject: [PATCH 16/20] test: add launchCapturingDeferred to MockProcessLauncher --- .../TestSupport/MockProcessLauncher.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift b/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift index d9b129c..f7b0466 100644 --- a/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift +++ b/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift @@ -42,4 +42,18 @@ struct MockProcessLauncher: ProcessLaunching { let key = executableURL.lastPathComponent return responses[key] ?? (exitCode: exitCode, output: output) } + + func launchCapturingDeferred( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> CapturedOutput { + if throwsOnCapture { throw CocoaError(.fileReadNoSuchFile) } + let key = executableURL.lastPathComponent + let response = responses[key] ?? (exitCode: exitCode, output: output) + return CapturedOutput(exitCode: response.exitCode, output: response.output, cleanup: {}) + } } From aa79e441767916bec64134555caca737c85e3e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:08:45 -0300 Subject: [PATCH 17/20] feat: add TestResultResolver for centralized result parsing --- .../Execution/TestResultResolver.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Sources/SwiftMutationTesting/Execution/TestResultResolver.swift diff --git a/Sources/SwiftMutationTesting/Execution/TestResultResolver.swift b/Sources/SwiftMutationTesting/Execution/TestResultResolver.swift new file mode 100644 index 0000000..e2f900c --- /dev/null +++ b/Sources/SwiftMutationTesting/Execution/TestResultResolver.swift @@ -0,0 +1,24 @@ +import Foundation + +struct TestResultResolver: Sendable { + let launcher: any ProcessLaunching + + func resolve( + launch: TestLaunchResult, + projectType: ProjectType, + timeout: TimeInterval + ) async throws -> TestRunOutcome { + switch projectType { + case .xcode: + return try await ResultParser(launcher: launcher).parse( + exitCode: launch.exitCode, + output: launch.output, + xcresultPath: launch.xcresultPath, + timeout: timeout + ) + + case .spm: + return SPMResultParser().parse(exitCode: launch.exitCode, output: launch.output) + } + } +} From 979f7a7377b43792a2c6fa7cf431690738a1be66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:09:14 -0300 Subject: [PATCH 18/20] refactor: unify launch result types and use deferred cleanup in execution stages --- .../IncompatibleMutantExecutor.swift | 54 +++++++++---------- .../IncompatibleTestLaunchResult.swift | 6 --- .../Execution/TestExecutionStage.swift | 16 +++--- .../Execution/TestLaunchResult.swift | 1 + 4 files changed, 37 insertions(+), 40 deletions(-) delete mode 100644 Sources/SwiftMutationTesting/Execution/IncompatibleTestLaunchResult.swift diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index 2949b86..40906c3 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -129,6 +129,10 @@ struct IncompatibleMutantExecutor: Sendable { try? FileManager.default.createSymbolicLink(atPath: sandboxFilePath, withDestinationPath: originalCanonical) } + try? FileManager.default.removeItem( + at: sandbox.rootURL.appendingPathComponent(".build/manifests") + ) + let build = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: spmBuildArguments(configuration: configuration), @@ -148,7 +152,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let test = try await deps.launcher.launchCapturing( + let test = try await deps.launcher.launchCapturingDeferred( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: testArgs, environment: nil, @@ -159,6 +163,7 @@ struct IncompatibleMutantExecutor: Sendable { let duration = Date().timeIntervalSince(start) let outcome = SPMResultParser().parse(exitCode: test.exitCode, output: test.output) + test.cleanup() let status = outcome.asExecutionStatus let index = await deps.counter.increment() @@ -186,7 +191,7 @@ struct IncompatibleMutantExecutor: Sendable { ) let slot = try await pool.acquire() - let launched: IncompatibleTestLaunchResult + let launched: TestLaunchResult do { launched = try await launch(slot: slot, sandbox: sandbox, configuration: configuration) } catch { @@ -195,23 +200,14 @@ struct IncompatibleMutantExecutor: Sendable { throw error } - let duration = launched.duration - await pool.release(slot) - - let outcome: TestRunOutcome - switch configuration.build.projectType { - case .xcode: - outcome = try await ResultParser(launcher: deps.launcher).parse( - exitCode: launched.exitCode, - output: launched.output, - xcresultPath: launched.xcresultPath, - timeout: configuration.build.timeout - ) - - case .spm: - outcome = SPMResultParser().parse(exitCode: launched.exitCode, output: launched.output) - } + let outcome = try await TestResultResolver(launcher: deps.launcher).resolve( + launch: launched, + projectType: configuration.build.projectType, + timeout: configuration.build.timeout + ) + launched.cleanup() + await pool.release(slot) try? sandbox.cleanup() let status = outcome.asExecutionStatus @@ -219,14 +215,14 @@ struct IncompatibleMutantExecutor: Sendable { let index = await deps.counter.increment() await deps.reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: total)) await deps.cacheStore.store(status: status, for: key) - return ExecutionResult(descriptor: mutant, status: status, testDuration: duration) + return ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) } private func launch( slot: SimulatorSlot, sandbox: Sandbox, configuration: RunnerConfiguration - ) async throws -> IncompatibleTestLaunchResult { + ) async throws -> TestLaunchResult { switch configuration.build.projectType { case .xcode(let scheme, _): return try await launchXcode( @@ -241,7 +237,7 @@ struct IncompatibleMutantExecutor: Sendable { slot: SimulatorSlot, sandbox: Sandbox, configuration: RunnerConfiguration - ) async throws -> IncompatibleTestLaunchResult { + ) async throws -> TestLaunchResult { let derivedDataPath = sandbox.rootURL.appendingPathComponent(".derived-data").path let xcresultPath = sandbox.rootURL .appendingPathComponent("\(UUID().uuidString).xcresult").path @@ -260,7 +256,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturingDeferred( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, environment: nil, @@ -269,18 +265,19 @@ struct IncompatibleMutantExecutor: Sendable { timeout: configuration.build.timeout ) - return IncompatibleTestLaunchResult( + return TestLaunchResult( exitCode: captured.exitCode, output: captured.output, xcresultPath: xcresultPath, - duration: Date().timeIntervalSince(start) + duration: Date().timeIntervalSince(start), + cleanup: captured.cleanup ) } private func launchSPM( sandbox: Sandbox, configuration: RunnerConfiguration - ) async throws -> IncompatibleTestLaunchResult { + ) async throws -> TestLaunchResult { var arguments = ["test"] if let testTarget = configuration.build.testTarget { @@ -288,7 +285,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturingDeferred( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, @@ -297,11 +294,12 @@ struct IncompatibleMutantExecutor: Sendable { timeout: configuration.build.timeout ) - return IncompatibleTestLaunchResult( + return TestLaunchResult( exitCode: captured.exitCode, output: captured.output, xcresultPath: "", - duration: Date().timeIntervalSince(start) + duration: Date().timeIntervalSince(start), + cleanup: captured.cleanup ) } diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleTestLaunchResult.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleTestLaunchResult.swift deleted file mode 100644 index 1ed55f2..0000000 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleTestLaunchResult.swift +++ /dev/null @@ -1,6 +0,0 @@ -struct IncompatibleTestLaunchResult: Sendable { - let exitCode: Int32 - let output: String - let xcresultPath: String - let duration: Double -} diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 9338dc2..0ff4cc1 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -58,7 +58,6 @@ struct TestExecutionStage: Sendable { await context.pool.release(slot) throw error } - await context.pool.release(slot) let outcome = try await ResultParser(launcher: deps.launcher).parse( exitCode: launched.exitCode, @@ -66,6 +65,8 @@ struct TestExecutionStage: Sendable { xcresultPath: launched.xcresultPath, timeout: context.configuration.build.timeout ) + launched.cleanup() + await context.pool.release(slot) try? FileManager.default.removeItem(atPath: launched.xcresultPath) let status = outcome.asExecutionStatus @@ -89,9 +90,10 @@ struct TestExecutionStage: Sendable { await context.pool.release(slot) throw error } - await context.pool.release(slot) let outcome = SPMResultParser().parse(exitCode: launched.exitCode, output: launched.output) + launched.cleanup() + await context.pool.release(slot) let status = outcome.asExecutionStatus if status == .unviable { let snippet = String(launched.output.prefix(300)).replacingOccurrences(of: "\n", with: "↵") @@ -115,7 +117,7 @@ struct TestExecutionStage: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturingDeferred( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, @@ -128,7 +130,8 @@ struct TestExecutionStage: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: "", - duration: Date().timeIntervalSince(start) + duration: Date().timeIntervalSince(start), + cleanup: captured.cleanup ) } @@ -161,7 +164,7 @@ struct TestExecutionStage: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturingDeferred( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, environment: nil, @@ -174,7 +177,8 @@ struct TestExecutionStage: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: xcresultPath, - duration: Date().timeIntervalSince(start) + duration: Date().timeIntervalSince(start), + cleanup: captured.cleanup ) } } diff --git a/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift b/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift index eb6fc13..c221d49 100644 --- a/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift +++ b/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift @@ -3,4 +3,5 @@ struct TestLaunchResult: Sendable { let output: String let xcresultPath: String let duration: Double + let cleanup: @Sendable () -> Void } From 003761f8bbe094291cedc98b3659d94c5c407b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:09:23 -0300 Subject: [PATCH 19/20] feat: reroute schema-excluded mutants and guarantee SimulatorPool tearDown --- .../Execution/MutantExecutor.swift | 112 ++++++++++++++++-- 1 file changed, 101 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index bf3cd78..8d8038c 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -44,15 +44,63 @@ struct MutantExecutor: Sendable { try await pool.setUp() await reporter.report(.simulatorPoolReady(size: pool.size)) + let results: [ExecutionResult] + do { + results = try await runAllMutants( + deps: deps, + input: input, + sandbox: sandbox, + pool: pool, + artifact: artifact, + schemaBuildExcluded: schemaBuildExcluded, + testFilesHash: testFilesHash + ) + } catch { + await pool.tearDown() + try? sandbox.cleanup() + throw error + } + + await pool.tearDown() + try? sandbox.cleanup() + try await cacheStore.persist() + + return results + } + + private func runAllMutants( + deps: ExecutionDeps, + input: RunnerInput, + sandbox: Sandbox, + pool: SimulatorPool, + artifact: BuildArtifact?, + schemaBuildExcluded: [MutantDescriptor], + testFilesHash: String + ) async throws -> [ExecutionResult] { + let schematizable = input.mutants.filter { $0.isSchematizable } + let incompatible = input.mutants.filter { !$0.isSchematizable } + var results: [ExecutionResult] = [] + var reroutedToIncompatible: [MutantDescriptor] = [] + var sourceCache: [String: String] = [:] + let rewriter = MutationRewriter() + for mutant in schemaBuildExcluded { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) - await deps.cacheStore.store(status: .unviable, for: key) - let index = await deps.counter.increment() - await deps.reporter.report( - .mutantFinished(descriptor: mutant, status: .unviable, index: index, total: deps.counter.total)) - results.append(ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0)) + if let rerouted = rewriteForIncompatible(mutant, rewriter: rewriter, sourceCache: &sourceCache) { + reroutedToIncompatible.append(rerouted) + } else { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + await deps.cacheStore.store(status: .unviable, for: key) + let index = await deps.counter.increment() + await deps.reporter.report( + .mutantFinished(descriptor: mutant, status: .unviable, index: index, total: deps.counter.total)) + results.append(ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0)) + } + } + + if !reroutedToIncompatible.isEmpty { + fputs("[xmr] rerouted \(reroutedToIncompatible.count) schema-excluded mutants to incompatible executor\n", stderr) } let excludedIDs = Set(schemaBuildExcluded.map(\.id)) @@ -72,13 +120,9 @@ struct MutantExecutor: Sendable { } results += try await runIncompatible( - deps: deps, mutants: incompatible, pool: pool, testFilesHash: testFilesHash + deps: deps, mutants: incompatible + reroutedToIncompatible, pool: pool, testFilesHash: testFilesHash ) - await pool.tearDown() - try? sandbox.cleanup() - try await cacheStore.persist() - return results } @@ -372,6 +416,52 @@ struct MutantExecutor: Sendable { return result.joined(separator: "\n") } + private func rewriteForIncompatible( + _ mutant: MutantDescriptor, + rewriter: MutationRewriter, + sourceCache: inout [String: String] + ) -> MutantDescriptor? { + let source: String + if let cached = sourceCache[mutant.filePath] { + source = cached + } else if let loaded = try? String(contentsOfFile: mutant.filePath, encoding: .utf8) { + sourceCache[mutant.filePath] = loaded + source = loaded + } else { + return nil + } + + let point = MutationPoint( + operatorIdentifier: mutant.operatorIdentifier, + filePath: mutant.filePath, + line: mutant.line, + column: mutant.column, + utf8Offset: mutant.utf8Offset, + originalText: mutant.originalText, + mutatedText: mutant.mutatedText, + replacement: mutant.replacementKind, + description: mutant.description + ) + + let content = rewriter.rewrite(source: source, applying: point) + guard content != source else { return nil } + + return MutantDescriptor( + id: mutant.id, + filePath: mutant.filePath, + line: mutant.line, + column: mutant.column, + utf8Offset: mutant.utf8Offset, + originalText: mutant.originalText, + mutatedText: mutant.mutatedText, + operatorIdentifier: mutant.operatorIdentifier, + replacementKind: mutant.replacementKind, + description: mutant.description, + isSchematizable: mutant.isSchematizable, + mutatedSourceContent: content + ) + } + private func canonicalPath(_ path: String) -> String { path.withCString { ptr in guard let resolved = realpath(ptr, nil) else { return path } From 008b97c9d8f623d0e3e132613c420eb492b214a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:09:39 -0300 Subject: [PATCH 20/20] test: update MutantExecutorTests for rerouted mutant expectations --- .../Unit/Execution/MutantExecutorTests.swift | 4 ++-- .../Unit/Simulator/SimulatorManagerTests.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index 2e22a28..e61cf1f 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -369,7 +369,7 @@ struct MutantExecutorTests { #expect(results.count == 2) let fooResult = results.first { $0.descriptor.id == "m0" } let barResult = results.first { $0.descriptor.id == "m1" } - #expect(fooResult?.status == .unviable) + #expect(fooResult?.status == .survived) #expect(barResult?.status == .survived) } @@ -411,7 +411,7 @@ struct MutantExecutorTests { #expect(results.count == 2) let r0 = results.first { $0.descriptor.id == "swift-mutation-testing_0" } let r1 = results.first { $0.descriptor.id == "swift-mutation-testing_1" } - #expect(r0?.status == .unviable) + #expect(r0?.status == .survived) #expect(r1?.status == .survived) } diff --git a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift index 0f76e91..5df730c 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift @@ -166,4 +166,5 @@ private actor SequentialOutputMock: ProcessLaunching { callIndex += 1 return (0, output) } + }