Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.swift-marshal-cache/
.swift-cpd-cache/
USP/
Makefile

# Generated by swift-mutation-testing
.swift-mutation-testing-cache/
Expand Down
20 changes: 14 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,25 +109,33 @@ repos:
args: ["format", "--in-place", "--parallel"]
types: [swift]

# Swift Clone & Pattern Detector
# Swift Code Duplication Detector
- repo: https://github.com/ericodx/swift-cpd
rev: v1.1.0
rev: v1.2.0
hooks:
- id: swift-cpd
name: ✓ Swift Clone & Pattern Detector
description: Detect duplicated code in Swift and Objective-C/C codebases.
name: ✓ Swift Code Duplication Detector
description: Detect and eliminate duplicated logic in Swift and Objective-C/C codebases to improve maintainability and code quality.
types: [swift]

# Swift Marshal
- repo: https://github.com/ericodx/swift-marshal
rev: v1.0.0
rev: v1.1.0
hooks:
- id: swift-marshal
name: ✓ Swift Marshal
description: Reorder Swift type members without rewriting code.
description: Ensure consistent member ordering in Swift types to improve readability and maintainability.
types: [swift]
args: ["--path", "Sources"]

# Gitleaks
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
name: ✓ Gitleaks
description: Detect hardcoded secrets and credentials before they are committed.

# Local hooks
- repo: local
hooks:
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftMutationTesting/CLI/HelpText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ enum HelpText {
<project-path> Path to the Xcode project root (default: .)

OPTIONS:
--scheme <scheme> Xcode scheme to build and test (required)
--destination <destination> xcodebuild destination specifier (required)
--scheme <scheme> Xcode scheme to build and test (Xcode projects only)
--destination <destination> xcodebuild destination specifier (Xcode projects only)
--target <test-target> Test target name
--timeout <seconds> Per-mutant test timeout in seconds (default: 120)
--concurrency <n> Number of parallel test workers (default: CPUs - 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,45 @@ struct ConfigurationFileWriter: Sendable {
}

private func generateContent(project: DetectedProject) -> String {
switch project.kind {
case .xcode(let scheme, let allSchemes, let destination):
return generateXcodeContent(
scheme: scheme,
allSchemes: allSchemes,
destination: destination,
testTarget: project.testTarget
)
case .spm(let testTargets):
return generateSPMContent(testTargets: testTargets, testTarget: project.testTarget)
}
}

private func generateXcodeContent(
scheme: String?,
allSchemes: [String],
destination: String,
testTarget: String?
) -> String {
var lines: [String] = []

lines.append("# swift-mutation-testing configuration")
lines.append("# All settings are optional. CLI flags override file values.")
lines.append("")

if project.allSchemes.count > 1 {
lines.append("# Available schemes: \(project.allSchemes.joined(separator: ", "))")
if allSchemes.count > 1 {
lines.append("# Available schemes: \(allSchemes.joined(separator: ", "))")
}

if let scheme = project.scheme {
if let scheme {
lines.append("scheme: \(scheme)")
} else {
lines.append("# scheme: MyApp")
}

lines.append("destination: \(project.destination)")
lines.append("destination: \(destination)")
lines.append("")

if let testTarget = project.testTarget {
if let testTarget {
lines.append("# Limit test execution to a specific target (recommended when the project has UI tests)")
lines.append("testTarget: \(testTarget)")
} else {
Expand All @@ -58,7 +77,7 @@ struct ConfigurationFileWriter: Sendable {
lines.append("")
lines.append("# Source file glob patterns to exclude from mutation")

if let testTarget = project.testTarget {
if let testTarget {
lines.append("exclude:")
lines.append(" - \"/\(testTarget)/\"")
} else {
Expand All @@ -71,6 +90,52 @@ struct ConfigurationFileWriter: Sendable {
return lines.joined(separator: "\n") + "\n"
}

private func generateSPMContent(testTargets: [String], testTarget: String?) -> String {
var lines: [String] = []

lines.append("# swift-mutation-testing configuration")
lines.append("# All settings are optional. CLI flags override file values.")
lines.append("")

if testTargets.count > 1 {
lines.append("# Available test targets: \(testTargets.joined(separator: ", "))")
}

if let testTarget {
lines.append("# Limit test execution to a specific target")
lines.append("testTarget: \(testTarget)")
} else {
lines.append("# Limit test execution to a specific target")
lines.append("# testTarget: MyPackageTests")
}

lines.append("")
lines.append("# Per-mutant test timeout in seconds (default: 120)")
lines.append("timeout: 120")
lines.append("")
lines.append("# Disable result cache (re-runs all mutants on every execution)")
lines.append("# noCache: true")
lines.append("")
lines.append("# Report output paths")
lines.append("# output: mutation-report.json")
lines.append("# htmlOutput: mutation-report.html")
lines.append("sonarOutput: sonar-mutation-report.json")
lines.append("")
lines.append("# Source file glob patterns to exclude from mutation")

if let testTarget {
lines.append("exclude:")
lines.append(" - \"/\(testTarget)/\"")
} else {
lines.append("# exclude:")
lines.append("# - \"**/Tests/**\"")
}

lines.append(contentsOf: mutatorsSection())

return lines.joined(separator: "\n") + "\n"
}

private func mutatorsSection() -> [String] {
var lines = ["", "# Mutation operators — set active: false to disable", "mutators:"]
for name in DiscoveryPipeline.allOperatorNames {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,16 @@ struct ConfigurationResolver: Sendable {
throw UsageError(message: "--concurrency must be >= 1")
}

guard cliArguments.build.scheme != nil || fileValues["scheme"] != nil else {
throw UsageError(message: "--scheme is required")
}

guard cliArguments.build.destination != nil || fileValues["destination"] != nil else {
throw UsageError(message: "--destination is required")
}
let projectType = try resolveProjectType(
cliArguments: cliArguments,
fileValues: fileValues,
projectPath: projectPath
)

return RunnerConfiguration(
projectPath: projectPath,
build: .init(
scheme: cliArguments.build.scheme ?? fileValues["scheme"] ?? "",
destination: cliArguments.build.destination ?? fileValues["destination"] ?? "",
projectType: projectType,
testTarget: cliArguments.build.testTarget ?? fileValues["testTarget"],
timeout: timeout,
concurrency: concurrency,
Expand All @@ -49,6 +46,35 @@ struct ConfigurationResolver: Sendable {
)
}

private func resolveProjectType(
cliArguments: ParsedArguments,
fileValues: [String: String],
projectPath: String
) throws -> ProjectType {
let scheme = cliArguments.build.scheme ?? fileValues["scheme"]
let destination = cliArguments.build.destination ?? fileValues["destination"]

if scheme == nil && destination == nil && hasSPMPackage(at: projectPath) {
return .spm
}

guard let scheme else {
throw UsageError(message: "--scheme is required")
}

guard let destination else {
throw UsageError(message: "--destination is required")
}

return .xcode(scheme: scheme, destination: destination)
}

private func hasSPMPackage(at projectPath: String) -> Bool {
let packageURL = URL(fileURLWithPath: projectPath)
.appendingPathComponent("Package.swift")
return FileManager.default.fileExists(atPath: packageURL.path)
}

private func resolvedTimeout(cli: ParsedArguments, fileValues: [String: String]) -> Double {
if let timeout = cli.build.timeout { return timeout }
if let timeout = fileValues["timeout"].flatMap(Double.init) { return timeout }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
struct DetectedProject: Sendable {
enum Kind: Sendable {
case xcode(scheme: String?, allSchemes: [String], destination: String)
case spm(testTargets: [String])
}

static let empty = DetectedProject(
scheme: nil, allSchemes: [], testTarget: nil, destination: "platform=macOS"
kind: .xcode(scheme: nil, allSchemes: [], destination: "platform=macOS"),
testTarget: nil
)

let scheme: String?
let allSchemes: [String]
let kind: Kind
let testTarget: String?
let destination: String

var scheme: String? {
guard case .xcode(let xScheme, _, _) = kind else { return nil }
return xScheme
}

var allSchemes: [String] {
guard case .xcode(_, let all, _) = kind else { return [] }
return all
}

var destination: String {
guard case .xcode(_, _, let dest) = kind else { return "platform=macOS" }
return dest
}
}
53 changes: 43 additions & 10 deletions Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,29 @@ struct ProjectDetector: Sendable {
func detect(at projectPath: String) async -> DetectedProject {
let projectURL = resolvedURL(for: projectPath)

guard let container = findContainer(in: projectURL) else {
return .empty
if let container = findContainer(in: projectURL) {
let (schemes, projectName, testTarget) = await listProject(
container: container, workingDirectory: projectURL)
let destination = await detectDestination(in: projectURL)
return DetectedProject(
kind: .xcode(
scheme: selectScheme(from: schemes, projectName: projectName),
allSchemes: schemes,
destination: destination
),
testTarget: testTarget
)
}

let (schemes, projectName, testTarget) = await listProject(container: container, workingDirectory: projectURL)
let destination = await detectDestination(in: projectURL)
if FileManager.default.fileExists(atPath: projectURL.appendingPathComponent("Package.swift").path) {
let testTargets = await listSPMTestTargets(in: projectURL)
return DetectedProject(
kind: .spm(testTargets: testTargets),
testTarget: testTargets.first
)
}

return DetectedProject(
scheme: selectScheme(from: schemes, projectName: projectName),
allSchemes: schemes,
testTarget: testTarget,
destination: destination
)
return .empty
}

private func resolvedURL(for path: String) -> URL {
Expand Down Expand Up @@ -70,6 +80,29 @@ struct ProjectDetector: Sendable {
return parseListOutput(result.output)
}

private func listSPMTestTargets(in projectURL: URL) async -> [String] {
guard
let result = try? await launcher.launchCapturing(
executableURL: URL(fileURLWithPath: "/usr/bin/swift"),
arguments: ["package", "dump-package"],
environment: nil,
workingDirectoryURL: projectURL,
timeout: 30
),
result.exitCode == 0,
let data = result.output.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let targets = json["targets"] as? [[String: Any]]
else {
return []
}

return
targets
.filter { ($0["type"] as? String) == "test" }
.compactMap { $0["name"] as? String }
}

private func parseListOutput(_ output: String) -> (schemes: [String], projectName: String?, testTarget: String?) {
guard
let data = output.data(using: .utf8),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
enum ProjectType: Sendable, Equatable {
case xcode(scheme: String, destination: String)
case spm
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ struct RunnerConfiguration: Sendable {
let filter: FilterOptions

struct BuildOptions: Sendable {
var scheme: String
var destination: String
var projectType: ProjectType
var testTarget: String?
var timeout: Double
var concurrency: Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ struct DiscoveryPipeline: Sendable {

return RunnerInput(
projectPath: input.projectPath,
scheme: input.scheme,
destination: input.destination,
projectType: input.projectType,
timeout: input.timeout,
concurrency: input.concurrency,
noCache: input.noCache,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
struct DiscoveryInput: Sendable {
let projectPath: String
let scheme: String
let destination: String
let projectType: ProjectType
let timeout: Double
let concurrency: Int
let noCache: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,15 @@ struct IncompatibleMutantExecutor: Sendable {
let xcresultPath = sandbox.rootURL
.appendingPathComponent("\(UUID().uuidString).xcresult").path

guard case .xcode(let scheme, _) = configuration.build.projectType else {
return IncompatibleTestLaunchResult(
exitCode: 0, output: "", xcresultPath: xcresultPath, duration: 0
)
}

var arguments = [
"test",
"-scheme", configuration.build.scheme,
"-scheme", scheme,
"-destination", slot.destination,
"-derivedDataPath", derivedDataPath,
"-resultBundlePath", xcresultPath,
Expand Down
Loading