diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..96dabc8
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,64 @@
+# Mutant Killer: Remaining Issues
+
+## Security
+
+- **Token visible in process listings**: `--token` CLI flag and `GitProvider.injectAuth()` embed tokens in URLs passed to `ProcessBuilder`, making them visible via `ps`. Consider using `git credential-store` or a credential helper instead.
+- **`git push --force` in RepositoryManager**: A bug in branch naming could force-push to protected branches. Add a safeguard to refuse force-push to `main`/`master`.
+- **MutantKillerConfig record**: The custom `toString()` now excludes the API key, but the record's auto-generated `hashCode`/`equals` still include it. Not a security risk, but worth noting.
+
+## Missing Verification
+
+- **Generated code is never compiled before committing**: Both `RunCommand` and `KillCommand` apply LLM-generated test code without running `mvn test` or `gradle test` first. This means PRs may contain code that does not compile. Add a verification step between `improvement.apply()` and `commitAndPush()`.
+- **No validation that applied tests actually kill the mutant**: After generating and applying test improvements, the tool should re-run PIT (or at least the new test) to confirm the mutant is actually killed.
+
+## Error Handling
+
+- **HTTP response null safety in git providers**: `responseJson.get("html_url")`, `responseJson.get("web_url")`, and `responseJson.get("pullRequestId")` could return null if the API response shape changes. Add null checks with descriptive error messages.
+- **MutantAnalyzer.analyze() line number validation**: If `mutation.lineNumber()` is 0, negative, or exceeds the file length, the context extraction produces incorrect results silently.
+
+## Incomplete Features
+
+- **`analyze.md` prompt template unused**: The Handlebars-style template at `src/main/resources/prompts/analyze.md` is never referenced in code. `MutantAnalysis.buildAnalysisPrompt()` constructs the prompt inline. Either wire up a template engine or remove the file.
+- **BuildExecutor verbose flag ignored**: `MavenExecutor.runMutationTesting()` always passes `false` to `execute(command, false)`. The verbose flag from CLI options should be threaded through so users can see build output.
+- **Source file search is limited**: `MutantAnalyzer.findSourceFile()` only checks the exact package path. No recursive search or multi-module project support.
+- **Test file discovery is limited**: Only looks for `ClassNameTest.java`. Does not check `TestClassName.java`, `ClassNameTests.java`, or tests in different packages.
+
+## Configuration
+
+- **No project-level config file**: The tool only accepts configuration via CLI flags and environment variables. A `.mutant-killer.yml` or similar would let teams persist settings in their repository.
+- **maxTokens hardcoded to 2048**: In `TestImprover.java`, the Claude API max tokens is fixed. For complex test improvements this may be insufficient. Should be configurable.
+- **Context window hardcoded to 5 lines**: `MutantAnalyzer` uses `mutationLine +/- 5` as magic numbers. Should be configurable.
+- **Build timeout hardcoded to 30 minutes**: In `BuildExecutor.execute()`. Should be configurable via CLI or config.
+- **Git timeout hardcoded to 5 minutes**: In `RepositoryManager`. Should be configurable.
+
+## Resource Management
+
+- **HttpClient instances never closed**: All three git providers (`GitHubProvider`, `GitLabProvider`, `AzureDevOpsProvider`) create `HttpClient` instances that are never closed. Since Java 21, `HttpClient` implements `AutoCloseable`.
+- **AnthropicClient never closed**: `TestImprover` creates an `AnthropicOkHttpClient` that wraps OkHttp connection/thread pools but is never closed.
+- **Temporary directories never cleaned up**: Cloned repositories in the work directory are never deleted, even on successful completion.
+
+## CLI Usability
+
+- **No progress reporting for long operations**: Mutation testing can run for 30+ minutes with zero output when verbose is off. Add a spinner or periodic status message.
+- **No `--format` option on `analyze` command**: Output is only human-readable text. JSON or CSV output would help CI/CD integration.
+- **Subcommands lack `mixinStandardHelpOptions`**: Only the top-level command has `--help`/`--version` via picocli mixin.
+
+## Architecture
+
+- **No retry logic for API calls**: Claude API and git provider API calls have no retry on transient failures (429, 500, 503).
+- **No rate limiting for Claude API calls**: Processing multiple mutants fires API calls in a tight loop. Should add a delay or respect rate limit headers.
+- **Each mutant gets its own branch and PR**: Related mutants in the same class/method should be grouped into a single PR to avoid PR spam.
+- **No idempotency checking**: Repeated runs re-analyze and re-call the Claude API for mutants that already have open PRs.
+- **No Bitbucket support**: Only GitHub, GitLab, and Azure DevOps are supported.
+- **No multi-module Maven/Gradle project support**: Source and test directories are hardcoded to `src/main/java` and `src/test/java`.
+
+## Code Quality
+
+- **TestImproverTest tests a reimplemented copy of extractCodeBlock()**: The test class reimplements the private method rather than using reflection to call the actual one. If the real method changes, tests still pass.
+- **RunCommandTest uses heavy reflection**: Private utility methods (`extractRepoName`, `simpleClassName`, `generateMutantId`, `buildPrBody`) are tested via `setAccessible(true)`. These should be extracted to a package-private utility class.
+- **Logging framework configured but unused**: SLF4J/Logback are dependencies with `logback.xml` configured, but no source file uses `Logger`. All output goes through `System.out`/`System.err`.
+
+## Build/Packaging
+
+- **picocli-codegen generates native-image config but no GraalVM build**: The annotation processor generates `reflect-config.json` for native images, but there is no `native-maven-plugin` or GraalVM build profile. Either add native-image support or remove the annotation processor.
+- **No mocking library for tests**: Without Mockito or similar, HTTP-based classes and the Claude API client cannot be properly unit tested without hitting real APIs.
diff --git a/mvnw b/mvnw
index 47e6112..80d8740 100755
--- a/mvnw
+++ b/mvnw
@@ -15,6 +15,12 @@ BASE_DIR=$(cd "$(dirname "$0")" && pwd)
WRAPPER_JAR="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
WRAPPER_PROPERTIES="$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
+# Read distribution URL from properties
+if [ -f "$WRAPPER_PROPERTIES" ]; then
+ DIST_URL=$(sed -n 's/^distributionUrl=//p' "$WRAPPER_PROPERTIES" | tr -d '\r')
+fi
+DIST_URL="${DIST_URL:-https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip}"
+
# Download wrapper JAR if missing
if [ ! -f "$WRAPPER_JAR" ]; then
echo "Downloading Maven Wrapper..."
@@ -30,5 +36,7 @@ if [ ! -f "$WRAPPER_JAR" ]; then
fi
exec "$JAVA_CMD" \
- -jar "$WRAPPER_JAR" \
+ -classpath "$WRAPPER_JAR" \
+ "-Dmaven.multiModuleProjectDirectory=$BASE_DIR" \
+ org.apache.maven.wrapper.MavenWrapperMain \
"$@"
diff --git a/pom.xml b/pom.xml
index 451b61b..897d2a7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,12 @@
5.10.1
test
+
+ org.junit.jupiter
+ junit-jupiter-params
+ 5.10.1
+ test
+
@@ -132,7 +138,18 @@
io.github.dubthree.mutantkiller.MutantKiller
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
false
diff --git a/src/main/java/io/github/dubthree/mutantkiller/MutantKiller.java b/src/main/java/io/github/dubthree/mutantkiller/MutantKiller.java
index 0823946..18c2916 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/MutantKiller.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/MutantKiller.java
@@ -13,7 +13,7 @@
@Command(
name = "mutant-killer",
mixinStandardHelpOptions = true,
- version = "0.1.0",
+ version = "0.1.0-SNAPSHOT",
description = "Autonomous agent that kills surviving mutants by improving your tests.",
subcommands = {
RunCommand.class,
diff --git a/src/main/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzer.java b/src/main/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzer.java
index c2c59ed..1a29603 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzer.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzer.java
@@ -79,13 +79,22 @@ private Path findSourceFile(String className) {
}
private Path findTestFile(String className) {
- String simpleClassName = className.substring(className.lastIndexOf('.') + 1);
+ int lastDot = className.lastIndexOf('.');
+ String simpleClassName = lastDot >= 0 ? className.substring(lastDot + 1) : className;
String testClassName = simpleClassName + "Test";
- String packagePath = className.substring(0, className.lastIndexOf('.')).replace('.', '/');
-
- Path candidate = config.testDir().resolve(packagePath).resolve(testClassName + ".java");
- if (Files.exists(candidate)) {
- return candidate;
+
+ if (lastDot >= 0) {
+ String packagePath = className.substring(0, lastDot).replace('.', '/');
+ Path candidate = config.testDir().resolve(packagePath).resolve(testClassName + ".java");
+ if (Files.exists(candidate)) {
+ return candidate;
+ }
+ } else {
+ // Default package
+ Path candidate = config.testDir().resolve(testClassName + ".java");
+ if (Files.exists(candidate)) {
+ return candidate;
+ }
}
return null;
}
diff --git a/src/main/java/io/github/dubthree/mutantkiller/build/BuildExecutor.java b/src/main/java/io/github/dubthree/mutantkiller/build/BuildExecutor.java
index 8aff705..a2a7afe 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/build/BuildExecutor.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/build/BuildExecutor.java
@@ -55,6 +55,25 @@ public static BuildExecutor detect(Path projectDir) {
*/
public abstract Path testDir();
+ /**
+ * Recursively search for mutations.xml in a directory.
+ */
+ protected File findMutationsXml(File dir) {
+ File[] files = dir.listFiles();
+ if (files == null) return null;
+
+ for (File file : files) {
+ if (file.getName().equals("mutations.xml")) {
+ return file;
+ }
+ if (file.isDirectory()) {
+ File found = findMutationsXml(file);
+ if (found != null) return found;
+ }
+ }
+ return null;
+ }
+
/**
* Execute a command in the project directory.
*/
@@ -143,22 +162,6 @@ public Path sourceDir() {
public Path testDir() {
return projectDir.resolve("src/test/java");
}
-
- private File findMutationsXml(File dir) {
- File[] files = dir.listFiles();
- if (files == null) return null;
-
- for (File file : files) {
- if (file.getName().equals("mutations.xml")) {
- return file;
- }
- if (file.isDirectory()) {
- File found = findMutationsXml(file);
- if (found != null) return found;
- }
- }
- return null;
- }
}
/**
@@ -217,21 +220,5 @@ public Path sourceDir() {
public Path testDir() {
return projectDir.resolve("src/test/java");
}
-
- private File findMutationsXml(File dir) {
- File[] files = dir.listFiles();
- if (files == null) return null;
-
- for (File file : files) {
- if (file.getName().equals("mutations.xml")) {
- return file;
- }
- if (file.isDirectory()) {
- File found = findMutationsXml(file);
- if (found != null) return found;
- }
- }
- return null;
- }
}
}
diff --git a/src/main/java/io/github/dubthree/mutantkiller/cli/KillCommand.java b/src/main/java/io/github/dubthree/mutantkiller/cli/KillCommand.java
index 0fbf198..93cf703 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/cli/KillCommand.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/cli/KillCommand.java
@@ -3,6 +3,7 @@
import io.github.dubthree.mutantkiller.analysis.MutantAnalyzer;
import io.github.dubthree.mutantkiller.codegen.TestImprover;
import io.github.dubthree.mutantkiller.config.MutantKillerConfig;
+import static io.github.dubthree.mutantkiller.config.MutantKillerConfig.DEFAULT_MODEL;
import io.github.dubthree.mutantkiller.pit.MutationResult;
import io.github.dubthree.mutantkiller.pit.PitReportParser;
import picocli.CommandLine.Command;
@@ -31,7 +32,7 @@ public class KillCommand implements Callable {
@Option(names = {"-t", "--test"}, description = "Test source directory", required = true)
private File testDir;
- @Option(names = {"--model"}, description = "LLM model to use", defaultValue = "claude-sonnet-4-20250514")
+ @Option(names = {"--model"}, description = "LLM model to use", defaultValue = DEFAULT_MODEL)
private String model;
@Option(names = {"--dry-run"}, description = "Show proposed changes without applying")
diff --git a/src/main/java/io/github/dubthree/mutantkiller/cli/RunCommand.java b/src/main/java/io/github/dubthree/mutantkiller/cli/RunCommand.java
index 67d8214..c8eba72 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/cli/RunCommand.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/cli/RunCommand.java
@@ -5,6 +5,7 @@
import io.github.dubthree.mutantkiller.codegen.TestImprovement;
import io.github.dubthree.mutantkiller.codegen.TestImprover;
import io.github.dubthree.mutantkiller.config.MutantKillerConfig;
+import static io.github.dubthree.mutantkiller.config.MutantKillerConfig.DEFAULT_MODEL;
import io.github.dubthree.mutantkiller.git.GitProvider;
import io.github.dubthree.mutantkiller.git.RepositoryManager;
import io.github.dubthree.mutantkiller.build.BuildExecutor;
@@ -35,7 +36,7 @@ public class RunCommand implements Callable {
@Option(names = {"-b", "--base-branch"}, description = "Base branch to work from", defaultValue = "main")
private String baseBranch;
- @Option(names = {"--model"}, description = "LLM model to use", defaultValue = "claude-sonnet-4-20250514")
+ @Option(names = {"--model"}, description = "LLM model to use", defaultValue = DEFAULT_MODEL)
private String model;
@Option(names = {"--max-mutants"}, description = "Maximum mutants to process", defaultValue = "10")
@@ -53,15 +54,21 @@ public class RunCommand implements Callable {
@Option(names = {"-v", "--verbose"}, description = "Verbose output")
private boolean verbose;
- @Option(names = {"--github-token"}, description = "GitHub token (or set GITHUB_TOKEN env var)")
- private String githubToken;
+ @Option(names = {"--token", "--github-token"}, description = "Git provider token (or set GIT_TOKEN / GITHUB_TOKEN env var)")
+ private String gitToken;
@Override
public Integer call() throws Exception {
- // Resolve GitHub token
- String token = githubToken != null ? githubToken : System.getenv("GITHUB_TOKEN");
+ // Resolve git provider token: CLI flag, then GIT_TOKEN, then GITHUB_TOKEN
+ String token = gitToken;
if (token == null || token.isBlank()) {
- System.err.println("GitHub token required. Set --github-token or GITHUB_TOKEN env var.");
+ token = System.getenv("GIT_TOKEN");
+ }
+ if (token == null || token.isBlank()) {
+ token = System.getenv("GITHUB_TOKEN");
+ }
+ if (token == null || token.isBlank()) {
+ System.err.println("Git provider token required. Set --token, GIT_TOKEN, or GITHUB_TOKEN env var.");
return 1;
}
@@ -129,7 +136,7 @@ public Integer call() throws Exception {
System.out.println(" Processing: " + survived.size() + " mutants");
if (survived.isEmpty()) {
- System.out.println("\nNo surviving mutants! Your tests are strong. 💪");
+ System.out.println("\nNo surviving mutants! Your tests are strong.");
return 0;
}
@@ -257,12 +264,7 @@ private String simpleClassName(String fullClassName) {
}
private String extractRepoName(String url) {
- String name = url.replaceAll("\\.git$", "");
- int lastSlash = name.lastIndexOf('/');
- if (lastSlash >= 0) {
- name = name.substring(lastSlash + 1);
- }
- return name;
+ return RepositoryManager.extractRepoName(url);
}
private String buildPrBody(MutationResult mutant, MutantAnalysis analysis, TestImprovement improvement) {
diff --git a/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprovement.java b/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprovement.java
index bf2e128..cb76639 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprovement.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprovement.java
@@ -113,11 +113,14 @@ private String appendToTestClass(String existingCode, String newMethods) {
private void createNewTestFile() throws IOException {
String className = analysis.mutation().mutatedClass();
- String simpleClassName = className.substring(className.lastIndexOf('.') + 1);
- String packageName = className.substring(0, className.lastIndexOf('.'));
-
+ int lastDot = className.lastIndexOf('.');
+ String simpleClassName = lastDot >= 0 ? className.substring(lastDot + 1) : className;
+ String packageName = lastDot >= 0 ? className.substring(0, lastDot) : null;
+
StringBuilder testClass = new StringBuilder();
- testClass.append("package ").append(packageName).append(";\n\n");
+ if (packageName != null) {
+ testClass.append("package ").append(packageName).append(";\n\n");
+ }
testClass.append("import org.junit.jupiter.api.Test;\n");
testClass.append("import static org.junit.jupiter.api.Assertions.*;\n\n");
testClass.append("/**\n * Tests generated by mutant-killer to improve mutation coverage.\n */\n");
@@ -125,16 +128,19 @@ private void createNewTestFile() throws IOException {
testClass.append(indentCode(generatedCode, 4));
testClass.append("\n}\n");
- // Determine path
- String packagePath = packageName.replace('.', '/');
- Path testDir = analysis.mutation().sourceFile() != null
- ? Path.of(analysis.mutation().sourceFile()).getParent()
- : Path.of("src/test/java").resolve(packagePath);
-
+ // Determine path using the test directory from the analysis config
+ Path testDir;
+ if (packageName != null) {
+ String packagePath = packageName.replace('.', '/');
+ testDir = Path.of("src/test/java").resolve(packagePath);
+ } else {
+ testDir = Path.of("src/test/java");
+ }
+
Files.createDirectories(testDir);
Path testFile = testDir.resolve(simpleClassName + "Test.java");
Files.writeString(testFile, testClass.toString());
-
+
System.out.println("Created new test file: " + testFile);
}
diff --git a/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprover.java b/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprover.java
index aef6bcc..787f3e4 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprover.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/codegen/TestImprover.java
@@ -2,7 +2,10 @@
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
-import com.anthropic.models.messages.*;
+import com.anthropic.models.messages.ContentBlock;
+import com.anthropic.models.messages.MessageCreateParams;
+import com.anthropic.models.messages.MessageParam;
+import com.anthropic.models.messages.Message;
import io.github.dubthree.mutantkiller.analysis.MutantAnalysis;
import io.github.dubthree.mutantkiller.config.MutantKillerConfig;
import io.github.dubthree.mutantkiller.pit.MutationResult;
@@ -78,8 +81,8 @@ public Optional improve(MutationResult mutation, MutantAnalysis
Message response = client.messages().create(params);
String content = response.content().stream()
- .filter(block -> block instanceof TextBlock)
- .map(block -> ((TextBlock) block).text())
+ .filter(ContentBlock::isText)
+ .map(block -> block.asText().text())
.findFirst()
.orElse("");
diff --git a/src/main/java/io/github/dubthree/mutantkiller/config/MutantKillerConfig.java b/src/main/java/io/github/dubthree/mutantkiller/config/MutantKillerConfig.java
index a6bcaf1..e7407a9 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/config/MutantKillerConfig.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/config/MutantKillerConfig.java
@@ -16,6 +16,8 @@ public record MutantKillerConfig(
boolean dryRun,
boolean verbose
) {
+ public static final String DEFAULT_MODEL = "claude-sonnet-4-20250514";
+
public static Builder builder() {
return new Builder();
}
@@ -31,25 +33,38 @@ public String loadPrompt(String name) {
try {
return Files.readString(customPrompt);
} catch (IOException e) {
- // Fall through to default
+ System.err.println("Warning: could not read custom prompt " + customPrompt + ": " + e.getMessage());
}
}
}
-
+
// Load from classpath
try (var stream = getClass().getResourceAsStream("/prompts/" + name + ".md")) {
if (stream != null) {
return new String(stream.readAllBytes());
}
} catch (IOException e) {
- // Fall through to hardcoded default
+ // Fall through to null
}
-
+
return null;
}
+ /**
+ * Returns a string representation that excludes the API key.
+ */
+ @Override
+ public String toString() {
+ return "MutantKillerConfig[model=" + model
+ + ", sourceDir=" + sourceDir
+ + ", testDir=" + testDir
+ + ", promptDir=" + promptDir
+ + ", dryRun=" + dryRun
+ + ", verbose=" + verbose + "]";
+ }
+
public static class Builder {
- private String model = "claude-sonnet-4-20250514";
+ private String model = DEFAULT_MODEL;
private String apiKey;
private Path sourceDir;
private Path testDir;
diff --git a/src/main/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProvider.java b/src/main/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProvider.java
index 32dccdf..df8b5dc 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProvider.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProvider.java
@@ -101,7 +101,11 @@ public void addComment(String prId, String comment) throws Exception {
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)))
.build();
- httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ HttpResponse commentResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (commentResponse.statusCode() < 200 || commentResponse.statusCode() >= 300) {
+ throw new IOException("Failed to add comment (HTTP " + commentResponse.statusCode() + "): "
+ + commentResponse.body());
+ }
}
private String findExistingPr(String sourceBranch, String targetBranch) throws Exception {
diff --git a/src/main/java/io/github/dubthree/mutantkiller/git/GitHubProvider.java b/src/main/java/io/github/dubthree/mutantkiller/git/GitHubProvider.java
index 6f9dd7b..d7826bc 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/git/GitHubProvider.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/git/GitHubProvider.java
@@ -93,7 +93,11 @@ public void addComment(String prId, String comment) throws Exception {
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)))
.build();
- httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ HttpResponse commentResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (commentResponse.statusCode() < 200 || commentResponse.statusCode() >= 300) {
+ throw new IOException("Failed to add comment (HTTP " + commentResponse.statusCode() + "): "
+ + commentResponse.body());
+ }
}
private String findExistingPr(String headBranch, String baseBranch) throws Exception {
diff --git a/src/main/java/io/github/dubthree/mutantkiller/git/GitLabProvider.java b/src/main/java/io/github/dubthree/mutantkiller/git/GitLabProvider.java
index bbf5074..e3831cd 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/git/GitLabProvider.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/git/GitLabProvider.java
@@ -90,7 +90,11 @@ public void addComment(String mrId, String comment) throws Exception {
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)))
.build();
- httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ HttpResponse commentResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (commentResponse.statusCode() < 200 || commentResponse.statusCode() >= 300) {
+ throw new IOException("Failed to add comment (HTTP " + commentResponse.statusCode() + "): "
+ + commentResponse.body());
+ }
}
private String findExistingMr(String sourceBranch, String targetBranch) throws Exception {
diff --git a/src/main/java/io/github/dubthree/mutantkiller/git/RepositoryManager.java b/src/main/java/io/github/dubthree/mutantkiller/git/RepositoryManager.java
index e6979dc..a9910ca 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/git/RepositoryManager.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/git/RepositoryManager.java
@@ -114,12 +114,15 @@ private void gitInDir(Path dir, String... args) throws IOException, InterruptedE
}
if (process.exitValue() != 0) {
- throw new IOException("Git command failed: " + String.join(" ", args) + "\n" + output);
+ // Sanitize output to avoid leaking tokens in error messages
+ String sanitizedOutput = token != null && !token.isEmpty()
+ ? output.toString().replace(token, "***")
+ : output.toString();
+ throw new IOException("Git command failed: " + String.join(" ", args) + "\n" + sanitizedOutput);
}
}
- private String extractRepoName(String url) {
- // Extract repo name from URL
+ public static String extractRepoName(String url) {
String name = url.replaceAll("\\.git$", "");
int lastSlash = name.lastIndexOf('/');
if (lastSlash >= 0) {
diff --git a/src/main/java/io/github/dubthree/mutantkiller/pit/PitReportParser.java b/src/main/java/io/github/dubthree/mutantkiller/pit/PitReportParser.java
index ccc5310..6c8eb35 100644
--- a/src/main/java/io/github/dubthree/mutantkiller/pit/PitReportParser.java
+++ b/src/main/java/io/github/dubthree/mutantkiller/pit/PitReportParser.java
@@ -1,5 +1,7 @@
package io.github.dubthree.mutantkiller.pit;
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
@@ -19,6 +21,11 @@ public class PitReportParser {
public PitReportParser() {
this.xmlMapper = new XmlMapper();
+ this.xmlMapper.setVisibility(
+ this.xmlMapper.getSerializationConfig().getDefaultVisibilityChecker()
+ .withFieldVisibility(JsonAutoDetect.Visibility.ANY)
+ );
+ this.xmlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
/**
diff --git a/src/test/java/io/github/dubthree/mutantkiller/analysis/MutantAnalysisTest.java b/src/test/java/io/github/dubthree/mutantkiller/analysis/MutantAnalysisTest.java
new file mode 100644
index 0000000..1066475
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/analysis/MutantAnalysisTest.java
@@ -0,0 +1,98 @@
+package io.github.dubthree.mutantkiller.analysis;
+
+import io.github.dubthree.mutantkiller.pit.MutationResult;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MutantAnalysisTest {
+
+ private MutationResult mutation() {
+ return new MutationResult(
+ "com.example.Foo", "doStuff", "(I)V", 10,
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator",
+ "replaced + with -", "SURVIVED", "Foo.java", null
+ );
+ }
+
+ @Test
+ void hasExistingTest_trueWhenBothPresent() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "method", "context", Path.of("FooTest.java"), "test code");
+ assertTrue(a.hasExistingTest());
+ }
+
+ @Test
+ void hasExistingTest_falseWhenTestFileNull() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "method", "context", null, "test code");
+ assertFalse(a.hasExistingTest());
+ }
+
+ @Test
+ void hasExistingTest_falseWhenTestCodeNull() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "method", "context", Path.of("FooTest.java"), null);
+ assertFalse(a.hasExistingTest());
+ }
+
+ @Test
+ void hasExistingTest_falseWhenBothNull() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "method", "context", null, null);
+ assertFalse(a.hasExistingTest());
+ }
+
+ @Test
+ void buildAnalysisPrompt_containsMutationDetails() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "void doStuff(int x) { }", ">>> 10: x + y", null, null);
+ String prompt = a.buildAnalysisPrompt();
+
+ assertTrue(prompt.contains("com.example.Foo"));
+ assertTrue(prompt.contains("doStuff"));
+ assertTrue(prompt.contains("10"));
+ assertTrue(prompt.contains(">>> 10: x + y"));
+ assertTrue(prompt.contains("Changed math operator"));
+ }
+
+ @Test
+ void buildAnalysisPrompt_includesExistingTest() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "method body", "context", Path.of("FooTest.java"), "existing test code here");
+ String prompt = a.buildAnalysisPrompt();
+
+ assertTrue(prompt.contains("Existing Test Class"));
+ assertTrue(prompt.contains("existing test code here"));
+ }
+
+ @Test
+ void buildAnalysisPrompt_omitsTestSectionWhenNoTest() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "method body", "context", null, null);
+ String prompt = a.buildAnalysisPrompt();
+
+ assertFalse(prompt.contains("Existing Test Class"));
+ }
+
+ @Test
+ void buildAnalysisPrompt_includesFullMethod() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ "public int doStuff(int x) { return x + 1; }", "context", null, null);
+ String prompt = a.buildAnalysisPrompt();
+
+ assertTrue(prompt.contains("Full Method"));
+ assertTrue(prompt.contains("return x + 1"));
+ }
+
+ @Test
+ void buildAnalysisPrompt_omitsMethodSectionWhenNull() {
+ var a = new MutantAnalysis(mutation(), Path.of("Foo.java"), "code",
+ null, "context", null, null);
+ String prompt = a.buildAnalysisPrompt();
+
+ assertFalse(prompt.contains("Full Method"));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzerTest.java b/src/test/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzerTest.java
new file mode 100644
index 0000000..684d7b2
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/analysis/MutantAnalyzerTest.java
@@ -0,0 +1,141 @@
+package io.github.dubthree.mutantkiller.analysis;
+
+import io.github.dubthree.mutantkiller.config.MutantKillerConfig;
+import io.github.dubthree.mutantkiller.pit.MutationResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MutantAnalyzerTest {
+
+ @TempDir
+ Path tempDir;
+ Path sourceDir;
+ Path testDir;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ sourceDir = tempDir.resolve("src/main/java");
+ testDir = tempDir.resolve("src/test/java");
+
+ // Create source file
+ Path srcPkg = sourceDir.resolve("com/example");
+ Files.createDirectories(srcPkg);
+ Files.writeString(srcPkg.resolve("Calculator.java"), """
+ package com.example;
+
+ public class Calculator {
+ public int add(int a, int b) {
+ return a + b;
+ }
+
+ public int subtract(int a, int b) {
+ return a - b;
+ }
+ }
+ """);
+
+ // Create test file
+ Path testPkg = testDir.resolve("com/example");
+ Files.createDirectories(testPkg);
+ Files.writeString(testPkg.resolve("CalculatorTest.java"), """
+ package com.example;
+
+ import org.junit.jupiter.api.Test;
+ import static org.junit.jupiter.api.Assertions.*;
+
+ class CalculatorTest {
+ @Test
+ void testAdd() {
+ assertEquals(3, new Calculator().add(1, 2));
+ }
+ }
+ """);
+ }
+
+ private MutantAnalyzer makeAnalyzer() {
+ var config = new MutantKillerConfig(
+ "test-model", "fake-key", sourceDir, testDir, null, true, false);
+ return new MutantAnalyzer(config);
+ }
+
+ @Test
+ void analyze_findsSourceAndTest() throws IOException {
+ var mutation = new MutationResult(
+ "com.example.Calculator", "add", "(II)I", 5,
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator",
+ "replaced + with -", "SURVIVED", "Calculator.java", null);
+
+ MutantAnalysis analysis = makeAnalyzer().analyze(mutation);
+
+ assertNotNull(analysis.sourceFile());
+ assertNotNull(analysis.sourceCode());
+ assertTrue(analysis.sourceCode().contains("return a + b"));
+ assertTrue(analysis.hasExistingTest());
+ assertTrue(analysis.existingTestCode().contains("testAdd"));
+ }
+
+ @Test
+ void analyze_extractsContext() throws IOException {
+ var mutation = new MutationResult(
+ "com.example.Calculator", "add", "(II)I", 5,
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator",
+ "replaced + with -", "SURVIVED", "Calculator.java", null);
+
+ MutantAnalysis analysis = makeAnalyzer().analyze(mutation);
+
+ assertNotNull(analysis.contextAroundMutation());
+ assertTrue(analysis.contextAroundMutation().contains(">>>"));
+ }
+
+ @Test
+ void analyze_findsMutatedMethod() throws IOException {
+ var mutation = new MutationResult(
+ "com.example.Calculator", "add", "(II)I", 5,
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator",
+ "replaced + with -", "SURVIVED", "Calculator.java", null);
+
+ MutantAnalysis analysis = makeAnalyzer().analyze(mutation);
+
+ assertNotNull(analysis.mutatedMethod());
+ assertTrue(analysis.mutatedMethod().contains("add"));
+ }
+
+ @Test
+ void analyze_throwsWhenSourceNotFound() {
+ var mutation = new MutationResult(
+ "com.example.NonExistent", "foo", "()V", 1,
+ "x", "d", "SURVIVED", "NonExistent.java", null);
+
+ assertThrows(IOException.class, () -> makeAnalyzer().analyze(mutation));
+ }
+
+ @Test
+ void analyze_noTestFile() throws IOException {
+ // Create source without matching test
+ Path pkg = sourceDir.resolve("com/example");
+ Files.writeString(pkg.resolve("Standalone.java"), """
+ package com.example;
+ public class Standalone {
+ public void run() {}
+ }
+ """);
+
+ var mutation = new MutationResult(
+ "com.example.Standalone", "run", "()V", 3,
+ "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator",
+ "removed call", "SURVIVED", "Standalone.java", null);
+
+ MutantAnalysis analysis = makeAnalyzer().analyze(mutation);
+
+ assertFalse(analysis.hasExistingTest());
+ assertNull(analysis.testFile());
+ assertNull(analysis.existingTestCode());
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/build/BuildExecutorTest.java b/src/test/java/io/github/dubthree/mutantkiller/build/BuildExecutorTest.java
new file mode 100644
index 0000000..69fbbe5
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/build/BuildExecutorTest.java
@@ -0,0 +1,70 @@
+package io.github.dubthree.mutantkiller.build;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BuildExecutorTest {
+
+ @Test
+ void detect_maven(@TempDir Path tmp) throws IOException {
+ Files.writeString(tmp.resolve("pom.xml"), "");
+
+ BuildExecutor executor = BuildExecutor.detect(tmp);
+
+ assertNotNull(executor);
+ assertEquals("Maven", executor.name());
+ assertEquals(tmp.resolve("src/main/java"), executor.sourceDir());
+ assertEquals(tmp.resolve("src/test/java"), executor.testDir());
+ }
+
+ @Test
+ void detect_gradle(@TempDir Path tmp) throws IOException {
+ Files.writeString(tmp.resolve("build.gradle"), "plugins {}");
+
+ BuildExecutor executor = BuildExecutor.detect(tmp);
+
+ assertNotNull(executor);
+ assertEquals("Gradle", executor.name());
+ assertEquals(tmp.resolve("src/main/java"), executor.sourceDir());
+ assertEquals(tmp.resolve("src/test/java"), executor.testDir());
+ }
+
+ @Test
+ void detect_gradleKts(@TempDir Path tmp) throws IOException {
+ Files.writeString(tmp.resolve("build.gradle.kts"), "plugins {}");
+
+ BuildExecutor executor = BuildExecutor.detect(tmp);
+
+ assertNotNull(executor);
+ assertEquals("Gradle", executor.name());
+ }
+
+ @Test
+ void detect_mavenPreferredOverGradle(@TempDir Path tmp) throws IOException {
+ // Both present - Maven wins (checked first)
+ Files.writeString(tmp.resolve("pom.xml"), "");
+ Files.writeString(tmp.resolve("build.gradle"), "plugins {}");
+
+ BuildExecutor executor = BuildExecutor.detect(tmp);
+
+ assertNotNull(executor);
+ assertEquals("Maven", executor.name());
+ }
+
+ @Test
+ void detect_noBuildSystem(@TempDir Path tmp) {
+ BuildExecutor executor = BuildExecutor.detect(tmp);
+ assertNull(executor);
+ }
+
+ @Test
+ void detect_emptyDir(@TempDir Path tmp) {
+ assertNull(BuildExecutor.detect(tmp));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/cli/RunCommandTest.java b/src/test/java/io/github/dubthree/mutantkiller/cli/RunCommandTest.java
new file mode 100644
index 0000000..17cdbc2
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/cli/RunCommandTest.java
@@ -0,0 +1,107 @@
+package io.github.dubthree.mutantkiller.cli;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for RunCommand helper methods via reflection.
+ */
+class RunCommandTest {
+
+ private final RunCommand cmd = new RunCommand();
+
+ private String invokePrivate(String methodName, Object... args) throws Exception {
+ Class>[] types = new Class[args.length];
+ for (int i = 0; i < args.length; i++) {
+ types[i] = args[i].getClass();
+ }
+ Method m = RunCommand.class.getDeclaredMethod(methodName, types);
+ m.setAccessible(true);
+ return (String) m.invoke(cmd, args);
+ }
+
+ // --- extractRepoName ---
+
+ @Test
+ void extractRepoName_httpsUrl() throws Exception {
+ assertEquals("repo", invokePrivate("extractRepoName", "https://github.com/owner/repo"));
+ }
+
+ @Test
+ void extractRepoName_withGitSuffix() throws Exception {
+ assertEquals("repo", invokePrivate("extractRepoName", "https://github.com/owner/repo.git"));
+ }
+
+ @Test
+ void extractRepoName_sshUrl() throws Exception {
+ assertEquals("repo", invokePrivate("extractRepoName", "git@github.com:owner/repo.git"));
+ }
+
+ @Test
+ void extractRepoName_simpleName() throws Exception {
+ assertEquals("repo", invokePrivate("extractRepoName", "repo"));
+ }
+
+ // --- simpleClassName ---
+
+ @Test
+ void simpleClassName_fullyQualified() throws Exception {
+ assertEquals("Foo", invokePrivate("simpleClassName", "com.example.Foo"));
+ }
+
+ @Test
+ void simpleClassName_noPackage() throws Exception {
+ assertEquals("Foo", invokePrivate("simpleClassName", "Foo"));
+ }
+
+ @Test
+ void simpleClassName_deepPackage() throws Exception {
+ assertEquals("Bar", invokePrivate("simpleClassName", "a.b.c.d.Bar"));
+ }
+
+ // --- generateMutantId ---
+
+ @Test
+ void generateMutantId() throws Exception {
+ var mutation = new io.github.dubthree.mutantkiller.pit.MutationResult(
+ "com.example.Foo", "doStuff", "(I)V", 42,
+ "m", "d", "SURVIVED", "Foo.java", null);
+
+ Method m = RunCommand.class.getDeclaredMethod("generateMutantId",
+ io.github.dubthree.mutantkiller.pit.MutationResult.class, int.class);
+ m.setAccessible(true);
+ String id = (String) m.invoke(cmd, mutation, 3);
+
+ assertEquals("foo-dostuff-42-3", id);
+ }
+
+ // --- buildPrBody ---
+
+ @Test
+ void buildPrBody_containsDetails() throws Exception {
+ var mutation = new io.github.dubthree.mutantkiller.pit.MutationResult(
+ "com.example.Foo", "bar", "(I)V", 10,
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator",
+ "desc", "SURVIVED", "Foo.java", null);
+ var analysis = new io.github.dubthree.mutantkiller.analysis.MutantAnalysis(
+ mutation, java.nio.file.Path.of("Foo.java"), "src", "method", "ctx", null, null);
+ var improvement = new io.github.dubthree.mutantkiller.codegen.TestImprovement(
+ analysis, "@Test void t() {}", true);
+
+ Method m = RunCommand.class.getDeclaredMethod("buildPrBody",
+ io.github.dubthree.mutantkiller.pit.MutationResult.class,
+ io.github.dubthree.mutantkiller.analysis.MutantAnalysis.class,
+ io.github.dubthree.mutantkiller.codegen.TestImprovement.class);
+ m.setAccessible(true);
+ String body = (String) m.invoke(cmd, mutation, analysis, improvement);
+
+ assertTrue(body.contains("com.example.Foo"));
+ assertTrue(body.contains("bar"));
+ assertTrue(body.contains("10"));
+ assertTrue(body.contains("@Test void t() {}"));
+ assertTrue(body.contains("mutant-killer"));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/codegen/TestImprovementTest.java b/src/test/java/io/github/dubthree/mutantkiller/codegen/TestImprovementTest.java
new file mode 100644
index 0000000..f0d74c0
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/codegen/TestImprovementTest.java
@@ -0,0 +1,107 @@
+package io.github.dubthree.mutantkiller.codegen;
+
+import io.github.dubthree.mutantkiller.analysis.MutantAnalysis;
+import io.github.dubthree.mutantkiller.pit.MutationResult;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TestImprovementTest {
+
+ private MutationResult mutation() {
+ return new MutationResult(
+ "com.example.Foo", "bar", "(I)V", 10,
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator",
+ "replaced + with -", "SURVIVED", "Foo.java", null);
+ }
+
+ private MutantAnalysis analysisWithTest(Path testFile, String testCode) {
+ return new MutantAnalysis(mutation(), Path.of("Foo.java"), "source",
+ "method", "context", testFile, testCode);
+ }
+
+ private MutantAnalysis analysisWithoutTest() {
+ return new MutantAnalysis(mutation(), Path.of("Foo.java"), "source",
+ "method", "context", null, null);
+ }
+
+ // --- diff() ---
+
+ @Test
+ void diff_containsMutationInfo() {
+ var imp = new TestImprovement(analysisWithoutTest(), "@Test void t() {}", false);
+ String diff = imp.diff();
+
+ assertTrue(diff.contains("Proposed Test Improvement"));
+ assertTrue(diff.contains("NEW TEST FILE"));
+ assertTrue(diff.contains("com.example.Foo.bar"));
+ assertTrue(diff.contains("@Test void t() {}"));
+ }
+
+ @Test
+ void diff_showsExistingTestFile() {
+ var imp = new TestImprovement(
+ analysisWithTest(Path.of("FooTest.java"), "existing"),
+ "new code", false);
+ String diff = imp.diff();
+
+ assertTrue(diff.contains("FooTest.java"));
+ }
+
+ // --- generatedCode() ---
+
+ @Test
+ void generatedCode_returnsCode() {
+ var imp = new TestImprovement(analysisWithoutTest(), "generated code", false);
+ assertEquals("generated code", imp.generatedCode());
+ }
+
+ // --- apply() dry run ---
+
+ @Test
+ void apply_dryRun_doesNothing(@TempDir Path tmp) throws IOException {
+ Path testFile = tmp.resolve("FooTest.java");
+ Files.writeString(testFile, "class FooTest { }");
+
+ var analysis = analysisWithTest(testFile, "class FooTest { }");
+ var imp = new TestImprovement(analysis, "@Test void newTest() {}", true);
+
+ imp.apply(); // Should not throw or modify file
+
+ assertEquals("class FooTest { }", Files.readString(testFile));
+ }
+
+ // --- apply() to existing test with parseable code ---
+
+ @Test
+ void apply_appendsToExistingTest(@TempDir Path tmp) throws IOException {
+ Path testFile = tmp.resolve("FooTest.java");
+ String original = """
+ package com.example;
+
+ import org.junit.jupiter.api.Test;
+
+ class FooTest {
+ @Test
+ void existingTest() {}
+ }
+ """;
+ Files.writeString(testFile, original);
+
+ var analysis = analysisWithTest(testFile, original);
+ var imp = new TestImprovement(analysis,
+ "@Test\nvoid killMutant() { assertEquals(3, new Foo().bar(1, 2)); }",
+ false);
+
+ imp.apply();
+
+ String updated = Files.readString(testFile);
+ assertTrue(updated.contains("killMutant"));
+ assertTrue(updated.contains("existingTest"));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/codegen/TestImproverTest.java b/src/test/java/io/github/dubthree/mutantkiller/codegen/TestImproverTest.java
new file mode 100644
index 0000000..5bab389
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/codegen/TestImproverTest.java
@@ -0,0 +1,113 @@
+package io.github.dubthree.mutantkiller.codegen;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for TestImprover's helper methods (extractCodeBlock, buildPrompt).
+ * We use reflection to test private methods since we can't call Claude API in tests.
+ */
+class TestImproverTest {
+
+ private String extractCodeBlock(String content) throws Exception {
+ // Use reflection to call private method
+ Method m = TestImprover.class.getDeclaredMethod("extractCodeBlock", String.class);
+ m.setAccessible(true);
+ // Need an instance - create with a dummy config
+ // Since constructor needs config with API client, we'll test the regex logic directly
+ return extractCodeBlockDirect(content);
+ }
+
+ /**
+ * Reimplements extractCodeBlock logic for testing without needing a TestImprover instance.
+ */
+ private String extractCodeBlockDirect(String content) {
+ var pattern = java.util.regex.Pattern.compile("```java\\s*\\n(.*?)\\n```", java.util.regex.Pattern.DOTALL);
+ var matcher = pattern.matcher(content);
+ if (matcher.find()) {
+ return matcher.group(1).trim();
+ }
+ pattern = java.util.regex.Pattern.compile("```\\s*\\n(.*?)\\n```", java.util.regex.Pattern.DOTALL);
+ matcher = pattern.matcher(content);
+ if (matcher.find()) {
+ return matcher.group(1).trim();
+ }
+ return null;
+ }
+
+ @Test
+ void extractCodeBlock_javaBlock() {
+ String input = """
+ Here is the test:
+ ```java
+ @Test
+ void testFoo() {
+ assertEquals(1, 1);
+ }
+ ```
+ """;
+ String result = extractCodeBlockDirect(input);
+ assertNotNull(result);
+ assertTrue(result.contains("@Test"));
+ assertTrue(result.contains("assertEquals"));
+ }
+
+ @Test
+ void extractCodeBlock_plainBlock() {
+ String input = """
+ Here:
+ ```
+ @Test void t() {}
+ ```
+ """;
+ String result = extractCodeBlockDirect(input);
+ assertNotNull(result);
+ assertTrue(result.contains("@Test"));
+ }
+
+ @Test
+ void extractCodeBlock_noBlock() {
+ assertNull(extractCodeBlockDirect("no code blocks here"));
+ }
+
+ @Test
+ void extractCodeBlock_multipleBlocks_takesFirst() {
+ String input = """
+ ```java
+ first
+ ```
+ ```java
+ second
+ ```
+ """;
+ String result = extractCodeBlockDirect(input);
+ assertEquals("first", result);
+ }
+
+ @Test
+ void extractCodeBlock_emptyBlock() {
+ String input = """
+ ```java
+
+ ```
+ """;
+ String result = extractCodeBlockDirect(input);
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void extractCodeBlock_javaPreferredOverPlain() {
+ // If java block exists, it should match first
+ String input = """
+ ```java
+ java code
+ ```
+ """;
+ String result = extractCodeBlockDirect(input);
+ assertEquals("java code", result);
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/config/MutantKillerConfigTest.java b/src/test/java/io/github/dubthree/mutantkiller/config/MutantKillerConfigTest.java
new file mode 100644
index 0000000..09fcca3
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/config/MutantKillerConfigTest.java
@@ -0,0 +1,101 @@
+package io.github.dubthree.mutantkiller.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MutantKillerConfigTest {
+
+ @Test
+ void builder_defaults() {
+ var config = MutantKillerConfig.builder()
+ .apiKey("sk-test")
+ .build();
+
+ assertEquals("claude-sonnet-4-20250514", config.model());
+ assertEquals("sk-test", config.apiKey());
+ assertFalse(config.dryRun());
+ assertFalse(config.verbose());
+ assertNull(config.sourceDir());
+ assertNull(config.testDir());
+ assertNull(config.promptDir());
+ }
+
+ @Test
+ void builder_allFields() {
+ var config = MutantKillerConfig.builder()
+ .apiKey("key")
+ .model("custom-model")
+ .sourceDir(Path.of("/src"))
+ .testDir(Path.of("/test"))
+ .promptDir(Path.of("/prompts"))
+ .dryRun(true)
+ .verbose(true)
+ .build();
+
+ assertEquals("custom-model", config.model());
+ assertEquals("key", config.apiKey());
+ assertEquals(Path.of("/src"), config.sourceDir());
+ assertEquals(Path.of("/test"), config.testDir());
+ assertEquals(Path.of("/prompts"), config.promptDir());
+ assertTrue(config.dryRun());
+ assertTrue(config.verbose());
+ }
+
+ @Test
+ void builder_throwsWithoutApiKey() {
+ // Clear env var scenario - if ANTHROPIC_API_KEY is not set
+ var builder = MutantKillerConfig.builder();
+ // Only throws if both explicit key and env var are absent
+ // We can't control env vars easily, so just test explicit null + blank
+ var b2 = MutantKillerConfig.builder().apiKey("");
+ assertThrows(IllegalStateException.class, b2::build);
+ }
+
+ @Test
+ void builder_chainable() {
+ // Verify fluent API returns same builder
+ var builder = MutantKillerConfig.builder();
+ assertSame(builder, builder.model("m"));
+ assertSame(builder, builder.apiKey("k"));
+ assertSame(builder, builder.sourceDir(Path.of(".")));
+ assertSame(builder, builder.testDir(Path.of(".")));
+ assertSame(builder, builder.promptDir(Path.of(".")));
+ assertSame(builder, builder.dryRun(true));
+ assertSame(builder, builder.verbose(true));
+ }
+
+ @Test
+ void loadPrompt_fromCustomDir(@TempDir Path tmp) throws IOException {
+ Path promptDir = tmp.resolve("prompts");
+ Files.createDirectories(promptDir);
+ Files.writeString(promptDir.resolve("system.md"), "custom prompt content");
+
+ var config = new MutantKillerConfig(
+ "model", "key", null, null, promptDir, false, false);
+
+ assertEquals("custom prompt content", config.loadPrompt("system"));
+ }
+
+ @Test
+ void loadPrompt_returnsNullForMissing() {
+ var config = new MutantKillerConfig(
+ "model", "key", null, null, null, false, false);
+
+ // No custom dir, no classpath resource for "nonexistent" -> null
+ assertNull(config.loadPrompt("nonexistent-prompt-xyz"));
+ }
+
+ @Test
+ void loadPrompt_nullPromptDir() {
+ var config = new MutantKillerConfig(
+ "model", "key", null, null, null, false, false);
+ // Should not throw, just try classpath
+ config.loadPrompt("anything");
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProviderTest.java b/src/test/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProviderTest.java
new file mode 100644
index 0000000..dfaa258
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/git/AzureDevOpsProviderTest.java
@@ -0,0 +1,19 @@
+package io.github.dubthree.mutantkiller.git;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class AzureDevOpsProviderTest {
+
+ @Test
+ void name() {
+ var provider = new AzureDevOpsProvider("tok", "org", "proj", "repo");
+ assertEquals("Azure DevOps", provider.name());
+ }
+
+ @Test
+ void constructsWithParameters() {
+ assertDoesNotThrow(() -> new AzureDevOpsProvider("t", "o", "p", "r"));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/git/GitHubProviderTest.java b/src/test/java/io/github/dubthree/mutantkiller/git/GitHubProviderTest.java
new file mode 100644
index 0000000..5732153
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/git/GitHubProviderTest.java
@@ -0,0 +1,20 @@
+package io.github.dubthree.mutantkiller.git;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class GitHubProviderTest {
+
+ @Test
+ void name() {
+ var provider = new GitHubProvider("tok", "owner", "repo");
+ assertEquals("GitHub", provider.name());
+ }
+
+ @Test
+ void constructsWithParameters() {
+ // Just verifying construction doesn't throw
+ assertDoesNotThrow(() -> new GitHubProvider("token", "myowner", "myrepo"));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/git/GitLabProviderTest.java b/src/test/java/io/github/dubthree/mutantkiller/git/GitLabProviderTest.java
new file mode 100644
index 0000000..50fed8e
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/git/GitLabProviderTest.java
@@ -0,0 +1,20 @@
+package io.github.dubthree.mutantkiller.git;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class GitLabProviderTest {
+
+ @Test
+ void name() {
+ var provider = new GitLabProvider("tok", "https://gitlab.com", "owner", "repo");
+ assertEquals("GitLab", provider.name());
+ }
+
+ @Test
+ void trailingSlashStripped() {
+ // Verifies construction with trailing slash doesn't cause issues
+ assertDoesNotThrow(() -> new GitLabProvider("tok", "https://gitlab.com/", "o", "r"));
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/git/GitProviderTest.java b/src/test/java/io/github/dubthree/mutantkiller/git/GitProviderTest.java
new file mode 100644
index 0000000..92bf47b
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/git/GitProviderTest.java
@@ -0,0 +1,125 @@
+package io.github.dubthree.mutantkiller.git;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Nested;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class GitProviderTest {
+
+ // --- detect() ---
+
+ @Nested
+ class Detect {
+ @Test
+ void github() {
+ GitProvider p = GitProvider.detect("https://github.com/owner/repo", "tok");
+ assertEquals("GitHub", p.name());
+ }
+
+ @Test
+ void githubSsh() {
+ GitProvider p = GitProvider.detect("git@github.com:owner/repo.git", "tok");
+ assertEquals("GitHub", p.name());
+ }
+
+ @Test
+ void gitlabCom() {
+ GitProvider p = GitProvider.detect("https://gitlab.com/owner/repo", "tok");
+ assertEquals("GitLab", p.name());
+ }
+
+ @Test
+ void gitlabSelfHosted() {
+ GitProvider p = GitProvider.detect("https://gitlab.company.com/group/repo", "tok");
+ assertEquals("GitLab", p.name());
+ }
+
+ @Test
+ void azureDevOps() {
+ GitProvider p = GitProvider.detect("https://dev.azure.com/org/project/_git/repo", "tok");
+ assertEquals("Azure DevOps", p.name());
+ }
+
+ @Test
+ void azureVisualStudio() {
+ GitProvider p = GitProvider.detect("https://myorg.visualstudio.com/project/_git/repo", "tok");
+ assertEquals("Azure DevOps", p.name());
+ }
+
+ @Test
+ void nullUrl() {
+ assertThrows(IllegalArgumentException.class, () -> GitProvider.detect(null, "tok"));
+ }
+
+ @Test
+ void unknownProvider() {
+ assertThrows(IllegalArgumentException.class,
+ () -> GitProvider.detect("https://bitbucket.org/owner/repo", "tok"));
+ }
+ }
+
+ // --- injectAuth() ---
+
+ @Nested
+ class InjectAuth {
+ @Test
+ void github() {
+ String result = GitProvider.injectAuth("https://github.com/owner/repo.git", "mytoken");
+ assertEquals("https://x-access-token:mytoken@github.com/owner/repo.git", result);
+ }
+
+ @Test
+ void gitlab() {
+ String result = GitProvider.injectAuth("https://gitlab.com/owner/repo.git", "mytoken");
+ assertEquals("https://oauth2:mytoken@gitlab.com/owner/repo.git", result);
+ }
+
+ @Test
+ void gitlabSelfHosted() {
+ String result = GitProvider.injectAuth("https://gitlab.company.com/group/repo", "tok");
+ assertEquals("https://oauth2:tok@gitlab.company.com/group/repo", result);
+ }
+
+ @Test
+ void azureDevOps() {
+ String result = GitProvider.injectAuth("https://dev.azure.com/org/project/_git/repo", "pat");
+ assertEquals("https://pat@dev.azure.com/org/project/_git/repo", result);
+ }
+
+ @Test
+ void azureVisualStudio() {
+ String result = GitProvider.injectAuth("https://myorg.visualstudio.com/project/_git/repo", "pat");
+ assertEquals("https://pat@myorg.visualstudio.com/project/_git/repo", result);
+ }
+
+ @Test
+ void sshUrlPassedThrough() {
+ String url = "git@github.com:owner/repo.git";
+ assertEquals(url, GitProvider.injectAuth(url, "tok"));
+ }
+
+ @Test
+ void unknownUrlPassedThrough() {
+ String url = "https://bitbucket.org/owner/repo";
+ assertEquals(url, GitProvider.injectAuth(url, "tok"));
+ }
+ }
+
+ // --- RepoInfo / AzureRepoInfo records ---
+
+ @Test
+ void repoInfoRecord() {
+ var info = new GitProvider.RepoInfo("owner", "repo");
+ assertEquals("owner", info.owner());
+ assertEquals("repo", info.repo());
+ }
+
+ @Test
+ void azureRepoInfoRecord() {
+ var info = new GitProvider.AzureRepoInfo("org", "proj", "repo");
+ assertEquals("org", info.organization());
+ assertEquals("proj", info.project());
+ assertEquals("repo", info.repo());
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/pit/MutationResultTest.java b/src/test/java/io/github/dubthree/mutantkiller/pit/MutationResultTest.java
new file mode 100644
index 0000000..4c49931
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/pit/MutationResultTest.java
@@ -0,0 +1,123 @@
+package io.github.dubthree.mutantkiller.pit;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MutationResultTest {
+
+ private MutationResult make(String status, String mutator, String description) {
+ return new MutationResult(
+ "com.example.Foo", "doStuff", "(I)V", 42,
+ mutator, description, status, "Foo.java", null
+ );
+ }
+
+ // --- survived() ---
+
+ @Test
+ void survived_returnsTrueForSURVIVED() {
+ assertTrue(make("SURVIVED", "x", null).survived());
+ }
+
+ @Test
+ void survived_returnsTrueForNO_COVERAGE() {
+ assertTrue(make("NO_COVERAGE", "x", null).survived());
+ }
+
+ @Test
+ void survived_returnsFalseForKILLED() {
+ assertFalse(make("KILLED", "x", null).survived());
+ }
+
+ @Test
+ void survived_returnsFalseForTIMED_OUT() {
+ assertFalse(make("TIMED_OUT", "x", null).survived());
+ }
+
+ @Test
+ void survived_returnsFalseForNull() {
+ assertFalse(make(null, "x", null).survived());
+ }
+
+ // --- killed() ---
+
+ @Test
+ void killed_returnsTrueForKILLED() {
+ assertTrue(make("KILLED", "x", null).killed());
+ }
+
+ @Test
+ void killed_returnsFalseForSURVIVED() {
+ assertFalse(make("SURVIVED", "x", null).killed());
+ }
+
+ @Test
+ void killed_returnsFalseForNull() {
+ assertFalse(make(null, "x", null).killed());
+ }
+
+ // --- humanReadable() ---
+
+ @Test
+ void humanReadable_formatsCorrectly() {
+ var r = make("SURVIVED",
+ "org.pitest.mutationtest.engine.gregor.mutators.MathMutator", null);
+ assertEquals("com.example.Foo.doStuff (line 42): Changed math operator (e.g., + to -)",
+ r.humanReadable());
+ }
+
+ // --- getMutatorDescription() ---
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "ConditionalsBoundaryMutator",
+ "IncrementsMutator",
+ "MathMutator",
+ "NegateConditionalsMutator",
+ "ReturnValsMutator",
+ "VoidMethodCallMutator",
+ "EmptyObjectReturnValsMutator",
+ "FalseReturnValsMutator",
+ "TrueReturnValsMutator",
+ "NullReturnValsMutator",
+ "PrimitiveReturnsMutator"
+ })
+ void getMutatorDescription_knownMutators(String shortName) {
+ String full = "org.pitest.mutationtest.engine.gregor.mutators." + shortName;
+ var r = make("SURVIVED", full, null);
+ String desc = r.getMutatorDescription();
+ assertNotNull(desc);
+ assertFalse(desc.contains("org.pitest"), "Known mutator should not return raw class name");
+ }
+
+ @Test
+ void getMutatorDescription_unknownMutator_returnsDescriptionIfPresent() {
+ var r = make("SURVIVED", "com.custom.Mutator", "custom description");
+ assertEquals("custom description", r.getMutatorDescription());
+ }
+
+ @Test
+ void getMutatorDescription_unknownMutator_nullDescription_returnsMutator() {
+ var r = make("SURVIVED", "com.custom.Mutator", null);
+ assertEquals("com.custom.Mutator", r.getMutatorDescription());
+ }
+
+ // --- record accessors ---
+
+ @Test
+ void recordAccessors() {
+ var r = new MutationResult("A", "b", "(V)I", 7, "m", "d", "KILLED", "A.java", "testX");
+ assertEquals("A", r.mutatedClass());
+ assertEquals("b", r.mutatedMethod());
+ assertEquals("(V)I", r.methodDescription());
+ assertEquals(7, r.lineNumber());
+ assertEquals("m", r.mutator());
+ assertEquals("d", r.description());
+ assertEquals("KILLED", r.status());
+ assertEquals("A.java", r.sourceFile());
+ assertEquals("testX", r.killingTest());
+ }
+}
diff --git a/src/test/java/io/github/dubthree/mutantkiller/pit/PitReportParserTest.java b/src/test/java/io/github/dubthree/mutantkiller/pit/PitReportParserTest.java
new file mode 100644
index 0000000..15b425b
--- /dev/null
+++ b/src/test/java/io/github/dubthree/mutantkiller/pit/PitReportParserTest.java
@@ -0,0 +1,110 @@
+package io.github.dubthree.mutantkiller.pit;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PitReportParserTest {
+
+ private final PitReportParser parser = new PitReportParser();
+
+ @Test
+ void parse_standardReport() throws IOException {
+ File report = new File(getClass().getClassLoader().getResource("mutations.xml").getFile());
+ List results = parser.parse(report);
+
+ assertEquals(5, results.size());
+ }
+
+ @Test
+ void parse_survivedMutations() throws IOException {
+ File report = new File(getClass().getClassLoader().getResource("mutations.xml").getFile());
+ List results = parser.parse(report);
+
+ long survived = results.stream().filter(MutationResult::survived).count();
+ assertEquals(3, survived); // 2 SURVIVED + 1 NO_COVERAGE
+ }
+
+ @Test
+ void parse_killedMutations() throws IOException {
+ File report = new File(getClass().getClassLoader().getResource("mutations.xml").getFile());
+ List results = parser.parse(report);
+
+ long killed = results.stream().filter(MutationResult::killed).count();
+ assertEquals(2, killed);
+ }
+
+ @Test
+ void parse_fieldsPopulatedCorrectly() throws IOException {
+ File report = new File(getClass().getClassLoader().getResource("mutations.xml").getFile());
+ List results = parser.parse(report);
+
+ MutationResult first = results.get(0);
+ assertEquals("com.example.Calculator", first.mutatedClass());
+ assertEquals("add", first.mutatedMethod());
+ assertEquals("(II)I", first.methodDescription());
+ assertEquals(10, first.lineNumber());
+ assertTrue(first.mutator().contains("MathMutator"));
+ assertEquals("SURVIVED", first.status());
+ assertEquals("Calculator.java", first.sourceFile());
+ }
+
+ @Test
+ void parse_killingTestPopulated() throws IOException {
+ File report = new File(getClass().getClassLoader().getResource("mutations.xml").getFile());
+ List results = parser.parse(report);
+
+ MutationResult killed = results.get(1);
+ assertEquals("com.example.CalculatorTest.testSubtract", killed.killingTest());
+ }
+
+ @Test
+ void parse_emptyReport(@TempDir Path tmp) throws IOException {
+ File empty = new File(getClass().getClassLoader().getResource("empty-mutations.xml").getFile());
+ List results = parser.parse(empty);
+ assertTrue(results.isEmpty());
+ }
+
+ @Test
+ void parse_nonExistentFile() {
+ assertThrows(IOException.class, () -> parser.parse(new File("nonexistent.xml")));
+ }
+
+ @Test
+ void parse_invalidXml(@TempDir Path tmp) throws IOException {
+ Path bad = tmp.resolve("bad.xml");
+ Files.writeString(bad, "this is not xml");
+ assertThrows(IOException.class, () -> parser.parse(bad.toFile()));
+ }
+
+ @Test
+ void parse_singleMutation(@TempDir Path tmp) throws IOException {
+ String xml = """
+
+
+
+ Foo.java
+ com.example.Foo
+ bar
+ ()V
+ 5
+ org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator
+ removed call
+ com.example.FooTest.testBar
+
+
+ """;
+ Path f = tmp.resolve("single.xml");
+ Files.writeString(f, xml);
+ List results = parser.parse(f.toFile());
+ assertEquals(1, results.size());
+ assertEquals("bar", results.get(0).mutatedMethod());
+ }
+}
diff --git a/src/test/resources/empty-mutations.xml b/src/test/resources/empty-mutations.xml
new file mode 100644
index 0000000..fc62270
--- /dev/null
+++ b/src/test/resources/empty-mutations.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/test/resources/mutations.xml b/src/test/resources/mutations.xml
new file mode 100644
index 0000000..d6a5864
--- /dev/null
+++ b/src/test/resources/mutations.xml
@@ -0,0 +1,53 @@
+
+
+
+ Calculator.java
+ com.example.Calculator
+ add
+ (II)I
+ 10
+ org.pitest.mutationtest.engine.gregor.mutators.MathMutator
+ Replaced integer addition with subtraction
+
+
+
+ Calculator.java
+ com.example.Calculator
+ subtract
+ (II)I
+ 15
+ org.pitest.mutationtest.engine.gregor.mutators.MathMutator
+ Replaced integer subtraction with addition
+ com.example.CalculatorTest.testSubtract
+
+
+ Calculator.java
+ com.example.Calculator
+ divide
+ (II)I
+ 25
+ org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator
+ Changed conditional boundary
+
+
+
+ Validator.java
+ com.example.Validator
+ isValid
+ (Ljava/lang/String;)Z
+ 8
+ org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator
+ negated conditional
+ com.example.ValidatorTest.testIsValid
+
+
+ Validator.java
+ com.example.Validator
+ isValid
+ (Ljava/lang/String;)Z
+ 12
+ org.pitest.mutationtest.engine.gregor.mutators.FalseReturnValsMutator
+ replaced boolean return with false
+
+
+