Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8dd52f0
docs: add mutation score badge
ericodx Apr 6, 2026
395a7e4
feat: add TestFileDiff for tracking test file changes
ericodx Apr 6, 2026
e5f8466
test: add TestFileDiff tests
ericodx Apr 6, 2026
c381dec
feat: add hashPerFile and testFilePaths to TestFilesHasher
ericodx Apr 6, 2026
f028bb0
test: add hashPerFile tests to TestFilesHasher
ericodx Apr 6, 2026
3406ba6
feat: add KillerTestFileResolver for test name to file path mapping
ericodx Apr 6, 2026
c084cd4
test: add KillerTestFileResolver tests
ericodx Apr 6, 2026
f81b817
feat: remove testFilesHash from MutantCacheKey
ericodx Apr 6, 2026
d4a6bd1
test: update MutantCacheKey tests for new key format
ericodx Apr 6, 2026
47d6418
feat: add killerTestFile to ExecutionResult and ExecutionDeps
ericodx Apr 6, 2026
425cd73
feat: add granular invalidation and metadata to CacheStore
ericodx Apr 6, 2026
403041f
test: add CacheStore invalidation and metadata tests
ericodx Apr 6, 2026
02cea5f
refactor: update TestExecutionStage to resolve killerTestFile
ericodx Apr 6, 2026
1764258
test: update TestExecutionStage tests for new cache key format
ericodx Apr 6, 2026
0fe1c00
refactor: update IncompatibleMutantExecutor to resolve killerTestFile
ericodx Apr 6, 2026
e1dac3b
test: update IncompatibleMutantExecutor tests for new cache key format
ericodx Apr 6, 2026
e2c3ae1
refactor: update FallbackExecutor to use new cache key format
ericodx Apr 6, 2026
cf98013
test: update FallbackExecutor tests for new cache key format
ericodx Apr 6, 2026
d1a152c
feat: implement granular cache invalidation pipeline in MutantExecutor
ericodx Apr 6, 2026
942cac3
test: add MutantExecutor allCached and invalidation tests
ericodx Apr 6, 2026
8b96e22
test: add RunnerSummary mixed cached and fresh results test
ericodx Apr 6, 2026
b34c757
test: add shared MutantDescriptor, ExecutionResult, and MutantCacheKe…
ericodx Apr 6, 2026
07d02e3
test: add shared RunnerConfiguration, RunnerInput, and DiscoveryInput…
ericodx Apr 6, 2026
3d858c1
test: add shared BuildArtifact, SimulatorPool, ExecutionDeps, and Fix…
ericodx Apr 6, 2026
ca9cf61
test: add shared stage-specific fixtures
ericodx Apr 6, 2026
4d04d9d
refactor: use shared fixtures in reporting tests
ericodx Apr 6, 2026
0080025
refactor: use shared fixtures in execution tests
ericodx Apr 6, 2026
6fd3637
refactor: use shared fixtures in discovery tests
ericodx Apr 6, 2026
2017717
refactor: use shared fixtures in cache, CLI, and sandbox tests
ericodx Apr 6, 2026
0fabb93
refactor: remove unused hash(projectPath:) from TestFilesHasher
ericodx Apr 6, 2026
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[![CI](https://img.shields.io/github/actions/workflow/status/ericodx/swift-mutation-testing/main-analysis.yml?branch=main&style=flat-square&logo=github&logoColor=white&label=CI&color=4CAF50)](https://github.com/ericodx/swift-mutation-testing/actions)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=deploy-on-friday-swift-mutation-testing&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=deploy-on-friday-swift-mutation-testing)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=deploy-on-friday-swift-mutation-testing&metric=coverage)](https://sonarcloud.io/summary/new_code?id=deploy-on-friday-swift-mutation-testing)
![mutation score](https://img.shields.io/badge/mutation%20score-85%25-lightgray?logo=jest&logoColor=white)

**Measure and improve test effectiveness in Swift codebases using mutation testing.**

Expand Down
106 changes: 103 additions & 3 deletions Sources/SwiftMutationTesting/Cache/CacheStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,65 @@ actor CacheStore {
init(storePath: String) {
self.storePath = storePath
self.entries = [:]
self.killerTestFiles = [:]
}

static let directoryName = ".swift-mutation-testing-cache"

private let storePath: String
private var entries: [MutantCacheKey: ExecutionStatus]
private var killerTestFiles: [MutantCacheKey: String]

private var metadataPath: String {
let url = URL(fileURLWithPath: storePath)
return url.deletingLastPathComponent().appendingPathComponent("metadata.json").path
}

private struct CacheEntry: Codable {
let key: MutantCacheKey
let status: ExecutionStatus
let killerTestFile: String?
}

struct CacheMetadata: Codable, Sendable {
let testFileHashes: [String: String]
}

func result(for key: MutantCacheKey) -> ExecutionStatus? {
entries[key]
}

func store(status: ExecutionStatus, for key: MutantCacheKey) {
func killerTestFile(for key: MutantCacheKey) -> String? {
killerTestFiles[key]
}

func store(status: ExecutionStatus, for key: MutantCacheKey, killerTestFile: String? = nil) {
entries[key] = status
if let killerTestFile {
killerTestFiles[key] = killerTestFile
}
}

func load() throws {
guard FileManager.default.fileExists(atPath: storePath) else { return }
let data = try Data(contentsOf: URL(fileURLWithPath: storePath))
let loaded = try JSONDecoder().decode([CacheEntry].self, from: data)
entries = Dictionary(uniqueKeysWithValues: loaded.map { ($0.key, $0.status) })
entries = [:]
for entry in loaded {
entries[entry.key] = entry.status
}
killerTestFiles = [:]
for entry in loaded {
if let file = entry.killerTestFile {
killerTestFiles[entry.key] = file
}
}
}

func persist() throws {
let cacheEntries = entries.map { CacheEntry(key: $0.key, status: $0.value) }
let cacheEntries = entries.map {
CacheEntry(key: $0.key, status: $0.value, killerTestFile: killerTestFiles[$0.key])
}
let data = try JSONEncoder().encode(cacheEntries)
let url = URL(fileURLWithPath: storePath)
try FileManager.default.createDirectory(
Expand All @@ -42,4 +72,74 @@ actor CacheStore {
)
try data.write(to: url, options: .atomic)
}

func loadMetadata() throws -> CacheMetadata? {
let url = URL(fileURLWithPath: metadataPath)
guard FileManager.default.fileExists(atPath: metadataPath) else { return nil }
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(CacheMetadata.self, from: data)
}

func persistMetadata(_ metadata: CacheMetadata) throws {
let data = try JSONEncoder().encode(metadata)
let url = URL(fileURLWithPath: metadataPath)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: url, options: .atomic)
}

func invalidate(diff: TestFileDiff) {
guard diff.hasChanges else { return }

let changedFiles = diff.modified.union(diff.removed)

for (key, status) in entries {
switch status {
case .unviable, .killedByCrash:
continue

case .killed:
guard let file = killerTestFiles[key] else {
entries.removeValue(forKey: key)
killerTestFiles.removeValue(forKey: key)
continue
}

if changedFiles.contains(file) {
entries.removeValue(forKey: key)
killerTestFiles.removeValue(forKey: key)
}

case .survived, .noCoverage, .timeout:
entries.removeValue(forKey: key)
killerTestFiles.removeValue(forKey: key)
}
}
}

func changedTestFiles(current: [String: String]) throws -> TestFileDiff {
guard let stored = try loadMetadata() else {
return TestFileDiff(
added: Set(current.keys),
modified: [],
removed: []
)
}

let storedKeys = Set(stored.testFileHashes.keys)
let currentKeys = Set(current.keys)

let added = currentKeys.subtracting(storedKeys)
let removed = storedKeys.subtracting(currentKeys)

var modified: Set<String> = []
for key in storedKeys.intersection(currentKeys) where stored.testFileHashes[key] != current[key] {
modified.insert(key)
}

return TestFileDiff(added: added, modified: modified, removed: removed)
}

}
51 changes: 51 additions & 0 deletions Sources/SwiftMutationTesting/Cache/KillerTestFileResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

struct KillerTestFileResolver: Sendable {
let testFilePaths: [String]

func resolve(testName: String) -> String? {
if let path = resolveXCTestClassName(testName) {
return path
}

if let path = resolveSwiftTestingFunctionName(testName) {
return path
}

return nil
}

private func resolveXCTestClassName(_ testName: String) -> String? {
let className: String
let components = testName.split(separator: ".")
guard components.count >= 2 else { return nil }

if components.count == 3 {
className = String(components[1])
} else {
className = String(components[0])
}

let fileName = "\(className).swift"
return testFilePaths.first { $0.hasSuffix("/\(fileName)") || $0 == fileName }
}

private func resolveSwiftTestingFunctionName(_ testName: String) -> String? {
let components = testName.split(separator: "/")
guard let lastComponent = components.last else { return nil }

let functionName = String(lastComponent)

for path in testFilePaths {
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { continue }

if content.contains("func \(functionName)")
|| content.contains("@Test") && content.contains(functionName)
{
return path
}
}

return nil
}
}
4 changes: 1 addition & 3 deletions Sources/SwiftMutationTesting/Cache/MutantCacheKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import Foundation

struct MutantCacheKey: Hashable, Sendable, Codable {
let fileContentHash: String
let testFilesHash: String
let operatorIdentifier: String
let utf8Offset: Int
let originalText: String
Expand All @@ -14,11 +13,10 @@ struct MutantCacheKey: Hashable, Sendable, Codable {
return digest.map { String(format: "%02x", $0) }.joined()
}

static func make(for mutant: MutantDescriptor, testFilesHash: String) -> MutantCacheKey {
static func make(for mutant: MutantDescriptor) -> MutantCacheKey {
let content = mutant.mutatedSourceContent ?? mutant.filePath
return MutantCacheKey(
fileContentHash: hash(of: content),
testFilesHash: testFilesHash,
operatorIdentifier: mutant.operatorIdentifier,
utf8Offset: mutant.utf8Offset,
originalText: mutant.originalText,
Expand Down
9 changes: 9 additions & 0 deletions Sources/SwiftMutationTesting/Cache/TestFileDiff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
struct TestFileDiff: Sendable {
let added: Set<String>
let modified: Set<String>
let removed: Set<String>

var hasChanges: Bool {
!added.isEmpty || !modified.isEmpty || !removed.isEmpty
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ struct ExecutionDeps: Sendable {
let cacheStore: CacheStore
let reporter: any ProgressReporter
let counter: MutationCounter
let killerTestFileResolver: KillerTestFileResolver
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
struct ExecutionResult: Sendable, Codable {

init(descriptor: MutantDescriptor, status: ExecutionStatus, testDuration: Double, killerTestFile: String? = nil) {
self.descriptor = descriptor
self.status = status
self.testDuration = testDuration
self.killerTestFile = killerTestFile
}

let descriptor: MutantDescriptor
let status: ExecutionStatus
let testDuration: Double
let killerTestFile: String?
}
29 changes: 16 additions & 13 deletions Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ struct FallbackExecutor: Sendable {
let deps: ExecutionDeps
let configuration: RunnerConfiguration

func execute(input: RunnerInput, pool: SimulatorPool, testFilesHash: String) async throws -> [ExecutionResult] {
func execute(input: RunnerInput, pool: SimulatorPool) async throws -> [ExecutionResult] {
var results: [ExecutionResult] = []

for file in input.schematizedFiles {
results += try await processFile(file: file, input: input, pool: pool, testFilesHash: testFilesHash)
results += try await processFile(file: file, input: input, pool: pool)
}

return results
Expand All @@ -15,14 +15,13 @@ struct FallbackExecutor: Sendable {
private func processFile(
file: SchematizedFile,
input: RunnerInput,
pool: SimulatorPool,
testFilesHash: String
pool: SimulatorPool
) async throws -> [ExecutionResult] {
let fileMutants = input.mutants.filter { $0.filePath == file.originalPath && $0.isSchematizable }

guard !fileMutants.isEmpty else { return [] }

if let cached = await cachedResults(for: fileMutants, testFilesHash: testFilesHash) {
if let cached = await cachedResults(for: fileMutants) {
return cached
}

Expand All @@ -48,7 +47,7 @@ struct FallbackExecutor: Sendable {
} catch {
await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false))
try? sandbox.cleanup()
return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash)
return await markUnviable(mutants: fileMutants)
}

case .spm:
Expand All @@ -61,28 +60,32 @@ struct FallbackExecutor: Sendable {
} catch {
await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false))
try? sandbox.cleanup()
return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash)
return await markUnviable(mutants: fileMutants)
}
}

let context = TestExecutionContext(
artifact: artifact, sandbox: sandbox, pool: pool,
configuration: configuration, testFilesHash: testFilesHash
configuration: configuration
)

let stageResults = try await TestExecutionStage(deps: deps).execute(mutants: fileMutants, in: context)
try? sandbox.cleanup()
return stageResults
}

private func cachedResults(for mutants: [MutantDescriptor], testFilesHash: String) async -> [ExecutionResult]? {
private func cachedResults(for mutants: [MutantDescriptor]) async -> [ExecutionResult]? {
guard !configuration.build.noCache else { return nil }

var results: [ExecutionResult] = []
for mutant in mutants {
let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash)
let key = MutantCacheKey.make(for: mutant)
guard let status = await deps.cacheStore.result(for: key) else { return nil }
results.append(ExecutionResult(descriptor: mutant, status: status, testDuration: 0))
let killerTestFile = await deps.cacheStore.killerTestFile(for: key)
results.append(
ExecutionResult(
descriptor: mutant, status: status, testDuration: 0, killerTestFile: killerTestFile
))
}

for result in results {
Expand All @@ -96,10 +99,10 @@ struct FallbackExecutor: Sendable {
return results
}

private func markUnviable(mutants: [MutantDescriptor], testFilesHash: String) async -> [ExecutionResult] {
private func markUnviable(mutants: [MutantDescriptor]) async -> [ExecutionResult] {
var results: [ExecutionResult] = []
for mutant in mutants {
let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash)
let key = MutantCacheKey.make(for: mutant)
await deps.cacheStore.store(status: .unviable, for: key)
let index = await deps.counter.increment()
await deps.reporter.report(
Expand Down
Loading