Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ For each invocation:
3. Read `target/site/jacoco/jacoco.xml`
4. Analyze selected Java files

### Projects That Don't Run Tests Through Maven

Pass `--test-command <cmd>` to run `<cmd>` instead of step 2's `mvn ... test`. This
is for projects whose tests aren't run via `mvn test` (a dedicated test runner
invoked directly with `java`, for example). `crap4java` still resolves and attaches
the JaCoCo runtime agent to `<cmd>` via `JAVA_TOOL_OPTIONS` (so any JVM it launches
contributes real coverage, not just an `mvn test`-driven one), then runs the JaCoCo
`report` goal afterward. `<cmd>`'s exit code still determines pass/fail.

## Build and Test

```bash
Expand Down Expand Up @@ -51,6 +60,7 @@ java -jar target/crap4java-0.1.0-SNAPSHOT.jar
--changed Analyze changed Java files under src/
<file ...> Analyze only these files
<directory ...> Analyze all Java files under each directory's src/ subtree
--test-command CMD Run CMD instead of `mvn test` (combinable with the forms above)
```

Examples:
Expand All @@ -61,6 +71,7 @@ java -jar target/crap4java-0.1.0-SNAPSHOT.jar
java -jar target/crap4java-0.1.0-SNAPSHOT.jar --changed
java -jar target/crap4java-0.1.0-SNAPSHOT.jar src/main/java/demo/Sample.java
java -jar target/crap4java-0.1.0-SNAPSHOT.jar module-a module-b
java -jar target/crap4java-0.1.0-SNAPSHOT.jar --test-command "scripts/run-unit-tests.sh"
```

## Exit codes
Expand Down
18 changes: 18 additions & 0 deletions spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ The tool shall exit with usage error when argument parsing fails.

The tool shall print usage text on CLI usage failure.

### 4.4 Test Command Override

The tool shall support an optional `--test-command <cmd>` flag, combinable with any form in §4.1 except `--help`.

When present, `<cmd>` replaces the test-execution step described in §7.2 as the mechanism that exercises the module's tests. Coverage instrumentation and JaCoCo report generation remain Maven-based; only test execution itself is substituted. This exists for projects whose tests are not run through `mvn test` (see §7.2.1).

`--test-command` shall require a value; omitting one is a usage error per §4.3.

## 5. File Selection Rules

### 5.1 Default Source Discovery
Expand Down Expand Up @@ -146,6 +154,16 @@ Before coverage generation, the tool shall delete stale module-local coverage ar

Coverage generation shall invoke Maven against the module root and generate JaCoCo XML for that module.

#### 7.2.1 Overridden Test Command

When `--test-command` (§4.4) is supplied, the tool shall:

1. resolve the JaCoCo runtime agent jar for the module (caching it under the module's `target/` so repeated runs do not re-resolve it)
2. run `<cmd>` with the JaCoCo agent attached via the `JAVA_TOOL_OPTIONS` environment variable, so any JVM `<cmd>` launches contributes coverage
3. invoke Maven's JaCoCo report goal against the module root to produce the JaCoCo XML report

A non-zero exit from `<cmd>` shall fail the run per §14 before the report goal runs.

### 7.3 Missing Coverage XML

If the expected JaCoCo XML file does not exist after coverage generation:
Expand Down
9 changes: 5 additions & 4 deletions src/crap4java/CliApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

Expand Down Expand Up @@ -37,25 +38,25 @@ int execute(String[] args) throws Exception {
return 0;
}

List<MethodMetrics> metrics = analyzeByModule(filesToAnalyze);
List<MethodMetrics> metrics = analyzeByModule(filesToAnalyze, parsed.testCommand());
metrics.sort(Comparator.comparing(MethodMetrics::crapScore,
Comparator.nullsLast(Comparator.reverseOrder())));
out.print(ReportFormatter.format(metrics));

double max = Main.maxCrap(metrics);
if (thresholdExceeded(max)) {
err.printf("CRAP threshold exceeded: %.1f > 8.0%n", max);
err.printf(Locale.ROOT, "CRAP threshold exceeded: %.1f > 8.0%n", max);
return 2;
}
return 0;
}

private List<MethodMetrics> analyzeByModule(List<Path> filesToAnalyze) throws Exception {
private List<MethodMetrics> analyzeByModule(List<Path> filesToAnalyze, String testCommand) throws Exception {
List<MethodMetrics> metrics = new ArrayList<>();
for (Map.Entry<Path, List<Path>> entry : groupByModuleRoot(filesToAnalyze).entrySet()) {
Path moduleRoot = entry.getKey();
Path jacocoXml = moduleRoot.resolve("target/site/jacoco/jacoco.xml");
coverageRunner.generateCoverage(moduleRoot);
coverageRunner.generateCoverage(moduleRoot, testCommand);
if (!Files.exists(jacocoXml)) {
err.println("Warning: JaCoCo XML not found at " + jacocoXml + ". Coverage will be N/A.");
}
Expand Down
2 changes: 1 addition & 1 deletion src/crap4java/CliArguments.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import java.util.List;

record CliArguments(CliMode mode, List<String> fileArgs) {
record CliArguments(CliMode mode, List<String> fileArgs, String testCommand) {
}

/* mutate4java-manifest
Expand Down
45 changes: 38 additions & 7 deletions src/crap4java/CliArgumentsParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,53 @@ final class CliArgumentsParser {
private CliArgumentsParser() {
}

static CliArguments parse(String[] args) {
if (args.length == 0) {
return new CliArguments(CliMode.ALL_SRC, List.of());
static CliArguments parse(String[] rawArgs) {
if (rawArgs.length == 0) {
return new CliArguments(CliMode.ALL_SRC, List.of(), null);
}

if (containsFlag(args, "--help")) {
return new CliArguments(CliMode.HELP, List.of());
if (containsFlag(rawArgs, "--help")) {
return new CliArguments(CliMode.HELP, List.of(), null);
}

String testCommand = extractTestCommand(rawArgs);
String[] args = withoutTestCommand(rawArgs);

if (args.length == 0) {
return new CliArguments(CliMode.ALL_SRC, List.of(), testCommand);
}

boolean changed = containsFlag(args, "--changed");
List<String> values = nonFlagArgs(args);
ensureChangedIsNotCombined(changed, values);
if (changed) {
return new CliArguments(CliMode.CHANGED_SRC, List.of());
return new CliArguments(CliMode.CHANGED_SRC, List.of(), testCommand);
}
return new CliArguments(CliMode.EXPLICIT_FILES, List.copyOf(values), testCommand);
}

private static String extractTestCommand(String[] args) {
for (int i = 0; i < args.length; i++) {
if ("--test-command".equals(args[i])) {
if (i + 1 >= args.length) {
throw new IllegalArgumentException("--test-command requires a value");
}
return args[i + 1];
}
}
return null;
}

private static String[] withoutTestCommand(String[] args) {
List<String> remaining = new ArrayList<>();
for (int i = 0; i < args.length; i++) {
if ("--test-command".equals(args[i])) {
i++;
continue;
}
remaining.add(args[i]);
}
return new CliArguments(CliMode.EXPLICIT_FILES, List.copyOf(values));
return remaining.toArray(new String[0]);
}

private static boolean containsFlag(String[] args, String flag) {
Expand Down
9 changes: 9 additions & 0 deletions src/crap4java/CommandExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

import java.nio.file.Path;
import java.util.List;
import java.util.Map;

interface CommandExecutor {
int run(List<String> command, Path directory) throws Exception;

default int runShell(String commandText, Path directory) throws Exception {
return runShell(commandText, directory, Map.of());
}

default int runShell(String commandText, Path directory, Map<String, String> extraEnv) throws Exception {
return run(List.of("/bin/sh", "-lc", commandText), directory);
}
}

/* mutate4java-manifest
Expand Down
57 changes: 50 additions & 7 deletions src/crap4java/CoverageRunner.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,71 @@
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

final class CoverageRunner {

private static final String JACOCO_VERSION = "0.8.12";

private final CommandExecutor executor;

CoverageRunner(CommandExecutor executor) {
this.executor = executor;
}

void generateCoverage(Path projectRoot) throws Exception {
void generateCoverage(Path projectRoot, String testCommand) throws Exception {
deleteIfExists(projectRoot.resolve("target/site/jacoco"));
deleteIfExists(projectRoot.resolve("target/jacoco.exec"));

int exit = executor.run(List.of(
"mvn", "-q",
"org.jacoco:jacoco-maven-plugin:0.8.12:prepare-agent",
"test",
"org.jacoco:jacoco-maven-plugin:0.8.12:report"
), projectRoot);
if (testCommand == null) {
run(List.of(
"mvn", "-q",
"org.jacoco:jacoco-maven-plugin:" + JACOCO_VERSION + ":prepare-agent",
"test",
"org.jacoco:jacoco-maven-plugin:" + JACOCO_VERSION + ":report"
), projectRoot, "Coverage command failed with exit ");
return;
}

runWithCustomTestCommand(projectRoot, testCommand);
}

private void runWithCustomTestCommand(Path projectRoot, String testCommand) throws Exception {
Path agentJar = resolveJacocoAgentJar(projectRoot);
Path destFile = projectRoot.resolve("target/jacoco.exec").toAbsolutePath();
Map<String, String> env = Map.of("JAVA_TOOL_OPTIONS",
"-javaagent:" + agentJar + "=destfile=" + destFile + ",append=true");

int exit = executor.runShell(testCommand, projectRoot, env);
if (exit != 0) {
throw new IllegalStateException("Coverage command failed with exit " + exit);
}

run(List.of("mvn", "-q", "org.jacoco:jacoco-maven-plugin:" + JACOCO_VERSION + ":report"),
projectRoot, "Coverage report command failed with exit ");
}

private Path resolveJacocoAgentJar(Path projectRoot) throws Exception {
Path agentJar = projectRoot.resolve("target/jacoco-agent/org.jacoco.agent-runtime.jar");
if (Files.exists(agentJar)) {
return agentJar.toAbsolutePath();
}
run(List.of("mvn", "-q", "dependency:copy",
"-Dartifact=org.jacoco:org.jacoco.agent:" + JACOCO_VERSION + ":jar:runtime",
"-DoutputDirectory=target/jacoco-agent",
"-Dmdep.stripVersion=true"
), projectRoot, "Unable to resolve the JaCoCo agent jar, exit ");
if (!Files.exists(agentJar)) {
throw new IllegalStateException("JaCoCo agent jar not found after resolution: " + agentJar);
}
return agentJar.toAbsolutePath();
}

private void run(List<String> command, Path directory, String failureMessage) throws Exception {
int exit = executor.run(command, directory);
if (exit != 0) {
throw new IllegalStateException(failureMessage + exit);
}
}

private void deleteIfExists(Path path) throws IOException {
Expand Down
8 changes: 8 additions & 0 deletions src/crap4java/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ static String usage() {
crap4java --changed Analyze changed Java files under src/
crap4java <path...> Analyze files, or for directory args analyze <dir>/src/**/*.java
crap4java --help Print this help message

Options:
--test-command CMD Run CMD instead of the default `mvn test` coverage
command, for projects that don't run tests through
Maven. CMD runs with the JaCoCo agent attached via
JAVA_TOOL_OPTIONS, so any JVM it launches (including
a custom test runner) still contributes coverage;
CMD's exit code determines pass/fail.
""";
}

Expand Down
18 changes: 14 additions & 4 deletions src/crap4java/ProcessCommandExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@

import java.nio.file.Path;
import java.util.List;
import java.util.Map;

final class ProcessCommandExecutor implements CommandExecutor {

@Override
public int run(List<String> command, Path directory) throws Exception {
Process process = new ProcessBuilder(command)
return start(command, directory, Map.of()).waitFor();
}

@Override
public int runShell(String commandText, Path directory, Map<String, String> extraEnv) throws Exception {
return start(List.of("/bin/sh", "-lc", commandText), directory, extraEnv).waitFor();
}

private Process start(List<String> command, Path directory, Map<String, String> extraEnv) throws Exception {
ProcessBuilder builder = new ProcessBuilder(command)
.directory(directory.toFile())
.inheritIO()
.start();
return process.waitFor();
.inheritIO();
builder.environment().putAll(extraEnv);
return builder.start();
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/crap4java/ReportFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;

final class ReportFormatter {

Expand All @@ -15,7 +16,7 @@ static String format(List<MethodMetrics> entries) {
.comparing((MethodMetrics e) -> e.crapScore() == null)
.thenComparing(e -> e.crapScore() == null ? 0.0 : -e.crapScore()));

String header = String.format("%-30s %-35s %4s %7s %8s", "Method", "Class", "CC", "Cov%", "CRAP");
String header = String.format(Locale.ROOT, "%-30s %-35s %4s %7s %8s", "Method", "Class", "CC", "Cov%", "CRAP");
String separator = "-".repeat(header.length());
StringBuilder builder = new StringBuilder();
builder.append("CRAP Report\n");
Expand All @@ -24,7 +25,7 @@ static String format(List<MethodMetrics> entries) {
builder.append(separator).append('\n');

for (MethodMetrics entry : sorted) {
builder.append(String.format("%-30s %-35s %4d %7s %8s%n",
builder.append(String.format(Locale.ROOT, "%-30s %-35s %4d %7s %8s%n",
entry.methodName(),
entry.className(),
entry.complexity(),
Expand All @@ -39,14 +40,14 @@ private static String formatCoverage(Double coverage) {
if (coverage == null) {
return " N/A ";
}
return String.format("%5.1f%%", coverage);
return String.format(Locale.ROOT, "%5.1f%%", coverage);
}

private static String formatCrap(Double score) {
if (score == null) {
return " N/A";
}
return String.format("%8.1f", score);
return String.format(Locale.ROOT, "%8.1f", score);
}
}

Expand Down
35 changes: 35 additions & 0 deletions test/crap4java/CliArgumentsParserTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,39 @@ void plainFilesDoNotTriggerChangedMode() {
assertEquals(CliMode.EXPLICIT_FILES, args.mode());
assertEquals(List.of("src/main/java/demo/A.java"), args.fileArgs());
}

@Test
void noTestCommandByDefault() {
CliArguments args = CliArgumentsParser.parse(new String[]{});
assertEquals(null, args.testCommand());
}

@Test
void testCommandFlagCapturesItsValue() {
CliArguments args = CliArgumentsParser.parse(
new String[]{"--test-command", "scripts/run-unit-tests.sh"});

assertEquals("scripts/run-unit-tests.sh", args.testCommand());
assertEquals(CliMode.ALL_SRC, args.mode());
}

@Test
void testCommandCanCombineWithChangedAndExplicitFiles() {
CliArguments changed = CliArgumentsParser.parse(
new String[]{"--changed", "--test-command", "scripts/run-unit-tests.sh"});
assertEquals(CliMode.CHANGED_SRC, changed.mode());
assertEquals("scripts/run-unit-tests.sh", changed.testCommand());

CliArguments explicit = CliArgumentsParser.parse(
new String[]{"src/main/java/demo/A.java", "--test-command", "scripts/run-unit-tests.sh"});
assertEquals(CliMode.EXPLICIT_FILES, explicit.mode());
assertEquals(List.of("src/main/java/demo/A.java"), explicit.fileArgs());
assertEquals("scripts/run-unit-tests.sh", explicit.testCommand());
}

@Test
void testCommandRequiresAValue() {
assertThrows(IllegalArgumentException.class,
() -> CliArgumentsParser.parse(new String[]{"--test-command"}));
}
}
Loading