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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ jobs:

- name: Build and test
run: ./gradlew build

# Builds a consumer project with the real plugin and checks both sides of the
# dual compilation: the production jar must be clean, the mutated one rejected.
- name: Test production jar check script
run: scripts/test-mutflow-verify-jar.sh
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Setup](#setup)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Verifying Production Artifacts](#verifying-production-artifacts)
- [Mutation Operators](#mutation-operators)
- [Features](#features)
- [How Mutations Work](#how-mutations-work)
Expand Down Expand Up @@ -380,6 +381,46 @@ MutFlow.underTest(run = 1, Selection.MostLikelyStable, Shuffle.PerChange) {

Selection strategies (`PureRandom`, `MostLikelyRandom`, `MostLikelyStable`) and shuffle modes (`PerRun`, `PerChange`) control how mutations are prioritized. The `@MutFlowTest` annotation uses sensible defaults automatically - these parameters are only needed for custom integrations.

## Verifying Production Artifacts

The Gradle plugin compiles mutations into a separate `mutatedMain` source set, so your production JAR never contains them. If you want a hard guarantee in your release pipeline (for example right before `docker build`), use the shipped check script:

```bash
scripts/mutflow-verify-jar.sh build/libs/my-app.jar
```

It scans every class in the archive, including nested archives such as Spring Boot's `BOOT-INF/lib/*.jar` and shadow JARs, and fails if a class references the mutflow runtime registry (`MutationRegistry`), which is what an injected mutation switch looks like in bytecode.

What is fine in a production artifact:

- the `mutflow-annotations` classes (`@MutationTarget`, `@SuppressMutations`) - `BINARY` retention markers with no runtime behavior
- your own classes annotated with `@MutationTarget`

What fails the check:

- any class carrying injected mutation switches
- bundled mutflow core/runtime classes (pass `--allow-bundled-runtime` to permit them)

Exit codes: `0` clean, `1` findings, `2` usage error or missing `unzip`.

Typical CI usage:

```bash
./gradlew build
scripts/mutflow-verify-jar.sh build/libs/*.jar || exit 1
docker build -t my-app .
```

Example failure output:

```
MUTATION build/libs/my-app.jar!/BOOT-INF/classes/com/example/PricingService.class
FAILED: found 1 class(es) containing mutflow mutations.
The artifact was built with the mutflow compiler plugin applied to production code.
```

The script requires `bash` and `unzip`. It is tested end-to-end by `scripts/test-mutflow-verify-jar.sh`, which runs on every pull request: it publishes mutflow to mavenLocal, builds a small consumer project with the real Gradle plugin, and asserts that the production JAR passes while the `mutatedMain` compilation of the very same sources is rejected.

## Mutation Operators

- [**Relational comparisons**](#how-relational-comparison-mutations-work) - `>`, `<`, `>=`, `<=` with 2 variants each (boundary + flip)
Expand Down
103 changes: 103 additions & 0 deletions scripts/mutflow-verify-jar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/bin/bash
#
# mutflow-verify-jar.sh - fail if a production artifact contains mutflow mutations.
#
# Usage: mutflow-verify-jar.sh [--allow-bundled-runtime] <artifact.jar> [more.jar ...]
#
# Mutated classes reference io/github/anschnapp/mutflow/MutationRegistry, so that
# reference is the marker we look for. Nested archives (Spring Boot BOOT-INF/lib,
# fat jars) are scanned as well. The mutflow-annotations classes (MutationTarget,
# SuppressMutations) are fine in production, the core/runtime classes are not
# (use --allow-bundled-runtime if you ship them on purpose).
#
# Exit codes: 0 = clean, 1 = findings, 2 = usage error or missing unzip.

MARKER="io/github/anschnapp/mutflow/MutationRegistry"
MUTFLOW_DIR="io/github/anschnapp/mutflow"

allow_runtime=0
mutations=0
runtimes=0
archives=0

usage() {
echo "Usage: mutflow-verify-jar.sh [--allow-bundled-runtime] <artifact.jar> [more.jar ...]"
}

# check_archive <file on disk> <display name>
check_archive() {
archives=$((archives + 1))
dir="$tmp/$archives"
mkdir "$dir"
if ! unzip -qq -o "$1" -d "$dir" >/dev/null 2>&1; then
echo "ERROR: cannot read archive: $2" >&2
exit 2
fi

# classes carrying injected mutation switches (mutflow's own classes may mention it)
for f in $(grep -rlF --include='*.class' "$MARKER" "$dir"); do
entry=${f#"$dir"/}
case "$entry" in "$MUTFLOW_DIR"/*) continue ;; esac
echo "MUTATION $2!/$entry"
mutations=$((mutations + 1))
done

# bundled mutflow core/runtime classes
for f in $(find "$dir/$MUTFLOW_DIR" -name '*.class' 2>/dev/null); do
case "${f##*/}" in MutationTarget.class | SuppressMutations.class) continue ;; esac
echo "RUNTIME $2!/${f#"$dir"/}"
runtimes=$((runtimes + 1))
done

# nested archives
for f in $(find "$dir" \( -name '*.jar' -o -name '*.war' \) 2>/dev/null); do
check_archive "$f" "$2!/${f#"$dir"/}"
done
}

if [ "$1" = "--allow-bundled-runtime" ]; then
allow_runtime=1
shift
fi

if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
usage
exit 0
fi

if [ $# -eq 0 ]; then
usage >&2
exit 2
fi

if ! command -v unzip >/dev/null 2>&1; then
echo "ERROR: 'unzip' is required but not installed" >&2
exit 2
fi

tmp=$(mktemp -d "${TMPDIR:-/tmp}/mutflow-verify.XXXXXX") || exit 2
trap 'rm -rf "$tmp"' EXIT

for jar in "$@"; do
if [ ! -f "$jar" ]; then
echo "ERROR: no such file: $jar" >&2
exit 2
fi
check_archive "$jar" "$jar"
done

if [ "$mutations" -gt 0 ]; then
echo "FAILED: found $mutations class(es) containing mutflow mutations." >&2
echo "The artifact was built with the mutflow compiler plugin applied to production code." >&2
exit 1
fi

if [ "$runtimes" -gt 0 ] && [ "$allow_runtime" -eq 0 ]; then
echo "FAILED: found $runtimes bundled mutflow runtime class(es)." >&2
echo "Only mutflow-annotations belongs on a production classpath." >&2
echo "Re-run with --allow-bundled-runtime if this is intentional." >&2
exit 1
fi

echo "OK: no mutations found."
exit 0
149 changes: 149 additions & 0 deletions scripts/test-mutflow-verify-jar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/bin/bash
#
# test-mutflow-verify-jar.sh - end-to-end test for mutflow-verify-jar.sh.
#
# Publishes mutflow to mavenLocal, generates a throwaway consumer project that
# applies the real Gradle plugin, and checks both sides of its dual compilation:
#
# main -> the production jar, must be clean (only @MutationTarget references)
# mutatedMain -> jarred here, must be rejected with exit code 1
#
# The project is generated instead of using example/ because example/ is not part
# of the repository, so CI has no copy of it.
#
# Run from anywhere: scripts/test-mutflow-verify-jar.sh
# Exit codes: 0 = all cases passed, 1 = a case failed.

root=$(cd "$(dirname "$0")/.." && pwd)
verify="$root/scripts/mutflow-verify-jar.sh"
kotlin_version=$(sed -n 's/^kotlinVersion=//p' "$root/gradle.properties")
mutflow_version="0.1.0-SNAPSHOT" # default project version, see root build.gradle.kts

tmp=$(mktemp -d "${TMPDIR:-/tmp}/mutflow-verify-test.XXXXXX") || exit 1
trap 'rm -rf "$tmp"' EXIT

failures=0

# expect <wanted exit code> <text that must appear> <args for mutflow-verify-jar.sh...>
expect() {
wanted_code=$1
wanted_text=$2
shift 2

output=$("$verify" "$@" 2>&1)
code=$?

if [ "$code" != "$wanted_code" ]; then
echo "FAIL: $* -> exit $code, expected $wanted_code"
echo "$output" | sed 's/^/ /'
failures=$((failures + 1))
return
fi
case "$output" in
*"$wanted_text"*) ;;
*)
echo "FAIL: $* -> output does not contain '$wanted_text'"
echo "$output" | sed 's/^/ /'
failures=$((failures + 1))
return
;;
esac
echo "ok: exit $code, contains '$wanted_text'"
}

echo "== publishing mutflow $mutflow_version to mavenLocal"
(cd "$root" && ./gradlew publishToMavenLocal -q) || exit 1

echo "== generating a consumer project that applies the mutflow Gradle plugin"
sample="$tmp/sample"
mkdir -p "$sample/src/main/kotlin/com/example"

cat > "$sample/settings.gradle.kts" <<EOF
pluginManagement {
repositories {
mavenLocal()
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenLocal()
mavenCentral()
}
}
rootProject.name = "verify-jar-sample"
EOF

cat > "$sample/build.gradle.kts" <<EOF
plugins {
kotlin("jvm") version "$kotlin_version"
id("io.github.anschnapp.mutflow") version "$mutflow_version"
}

group = "com.example"
version = "1.0"
EOF

cat > "$sample/src/main/kotlin/com/example/Calculator.kt" <<'EOF'
package com.example

import io.github.anschnapp.mutflow.MutationTarget

@MutationTarget
class Calculator {
fun isPositive(x: Int): Boolean = x > 0
}
EOF

echo "== building the consumer project (real plugin, dual compilation)"
(cd "$root" && ./gradlew -p "$sample" jar mutatedMainClasses -q) || exit 1

prod_jar=$(ls "$sample"/build/libs/*.jar 2>/dev/null | head -1)
mutated_classes="$sample/build/classes/kotlin/mutatedMain"
if [ ! -f "$prod_jar" ] || [ ! -d "$mutated_classes" ]; then
echo "ERROR: consumer build output not found (jar: $prod_jar, classes: $mutated_classes)" >&2
exit 1
fi

# guard against a vacuous "clean jar" result
if ! unzip -Z1 "$prod_jar" | grep -q 'com/example/Calculator.class'; then
echo "ERROR: production jar does not contain the expected class" >&2
exit 1
fi

# same class as the production jar, but from the mutated compilation
(cd "$mutated_classes" && jar cf "$tmp/mutated.jar" .) || exit 1

# a Spring Boot style artifact with the mutated jar nested inside
mkdir -p "$tmp/boot/BOOT-INF/lib"
cp "$tmp/mutated.jar" "$tmp/boot/BOOT-INF/lib/lib.jar"
(cd "$tmp/boot" && jar cf "$tmp/boot.jar" .) || exit 1

echo "== production jar must be clean"
expect 0 "OK: no mutations found" "$prod_jar"

echo "== mutated compilation must be rejected"
expect 1 "MUTATION" "$tmp/mutated.jar"
expect 1 "com/example/Calculator.class" "$tmp/mutated.jar"

echo "== mutations inside a nested jar must be found"
expect 1 "BOOT-INF/lib/lib.jar!/com/example/Calculator.class" "$tmp/boot.jar"

echo "== bundled mutflow core is reported, annotations are fine"
core_jar=$(ls "$root"/mutflow-core/build/libs/*.jar | grep -v -e sources -e javadoc | head -1)
annotations_jar=$(ls "$root"/mutflow-annotations/build/libs/*.jar | grep -v -e sources -e javadoc | head -1)
expect 1 "RUNTIME" "$core_jar"
expect 0 "OK: no mutations found" "$annotations_jar"

echo "== usage errors"
expect 2 "no such file" "$tmp/does-not-exist.jar"
expect 2 "Usage:"

if [ "$failures" -gt 0 ]; then
echo "$failures case(s) FAILED"
exit 1
fi

echo "all cases passed"
exit 0