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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ build
.idea
CLAUDE.md
example
# Written by native (linuxX64/macosArm64) inspector test runs into the module
# root rather than build/ (Kotlin/Native test process working directory).
mutflow-test-kmp/inspect-results/
91 changes: 90 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,62 @@ The environment variable override is intentional: it allows the same test code t
The `mutflow-core` module contains the bridge between compiler-generated code and test
runtime. Both sides depend on it, but not on each other, keeping coupling minimal.

### Multiplatform Architecture (KMR)

mutflow's mutation engine is **target-agnostic**: every operator is defined and applied on
Kotlin's common IR (post-FIR), and the same mutation points and IDs appear on every backend.
The backends simply lower the resulting guarded `when`/conditionals naturally:

| Backend | Lowering target | Notes |
|---|---|---|
| JVM | JVM bytecode | `IF_ICMP*`, `IADD`/`ISUB`, `IRETURN`, … |
| Kotlin/JS | JS operators | `+`→`-`, `===`→`!==`, `&&`→`||`, … |
| Kotlin/WASM | WASM | same JS-style conditionals compiled to WASM |
| Kotlin/Native | LLVM IR | `icmp slt`→`icmp sle`, `add`→`sub`, `br`→`select`, … |

**Module layout (multiplatform):**

```
mutflow-annotations commonMain @MutationTarget, @SuppressMutations
mutflow-core commonMain MutationRegistry, session mgmt, discovery
jvmMain real synchronized + java.util.concurrent
jsMain single-threaded (no-op lock)
wasmJsMain single-threaded (no-op lock)
nativeMain sequential (no-op lock; TODO: real mutex)
mutflow-runtime commonMain MutFlow, MutFlowSession, selection/shuffle, traps, reporting
jvmMain real thread-id + ConcurrentHashMap
jsMain/wasmJsMain/nativeMain single-threaded constants
mutflow-compiler-plugin K2 IR plugin (JVM-hosted, applies to all targets)
mutflow-gradle-plugin Gradle plugin (JVM + KMP wiring)
mutflow-test-kmp sample: jvm(), js(IR), wasmJs(), linuxX64()
```

**Key design points:**

1. **Single source of truth at IR level.** All operators live in the compiler plugin and
match on common IR. The same `MutationRegistry.check(...)` call is injected regardless
of target, so mutation IDs and source locations are identical across backends.

2. **Test-only injection.** On JVM, the Gradle plugin uses a dedicated `mutatedMain` source
set (dual compilation) so production artifacts never contain mutation markers. On KMP,
the plugin is applied to every compilation, but injection is gated on `@MutationTarget`
(or configured target patterns) — production code is untouched unless explicitly
annotated. The optional CLI safe-guard verification (see README) can additionally assert
that release artifacts contain no mutation markers.

3. **Multiplatform runtime.** `MutationRegistry`, session management, discovery, selection
strategies, and timeout detection all live in `commonMain`. The only expect/actual
pieces are the concurrent collection factories and the session lock
(`withSessionLock`), which are trivial per platform.

4. **Backend-specific lowering is mechanical.** The IR injection is identical; each backend
lowers the guarded `when` naturally. No per-backend mutation logic exists.

**Cross-backend consistency.** The `mutflow-test-kmp` sample runs the same
`CalculatorTest` on JVM, JS, WASM, and Native, asserting that the same mutation points are
discovered and killed on every backend. This is the primary regression guard for
target-agnosticism.

### Session-Based Architecture

State is scoped to sessions rather than being globally mutable:
Expand Down Expand Up @@ -869,6 +925,23 @@ Code only reached outside `MutFlow.underTest { }` blocks produces no mutations.
- Only matches functions that return Unit, have non-empty bodies, and are not property accessors
- Catches tests that don't verify side effects - "what if this function did nothing?"
- Operates at the function declaration level, not at call sites
- Additional operators (see `MutationCatalog.kt` and `docs/mutation-catalog.md`):
- `BitwiseOperator` — `and`↔`or`, `xor`→`and`/`or`, `shl`↔`shr`, `ushr`→`shl` (integer types)
- `UnaryMinusOperator` — `-a` → `a`
- `IncrementOperator` — `++` ↔ `--`; `RemoveIncrementOperator` (experimental) — `a++` → `a`
- `ReplaceNonVoidCallOperator` — non-void call → default value of return type
- `PrimitiveReturnOperator` — numeric return → `0`; `ObjectReturnOperator` — object return → `null`
- `BooleanConstOperator` — boolean literal `true` ↔ `false`; `StringLiteralOperator` — string → `""`
- `ConstructorCallOperator` — constructor call → `null`
- `ForceConditionalOperator` — force `if` condition to `true`/`false`
- `StringMethodOperator` — `endsWith`↔`startsWith`, `toUpperCase`↔`toLowerCase`, `trim`→`""`
- `CollectionMethodOperator` — `filter`↔`filterNot`, `any`↔`all`, `take`↔`drop`,
`isEmpty`↔`isNotEmpty`, `min`↔`max`, `minBy`↔`maxBy` (indexOf↔lastIndexOf omitted — WASM-incompatible)
- `ReferenceEqualityOperator` — `===`↔`!==`
- `ElvisOperator` — `a ?: b` → `a` / `b`
- `SafeCallOperator` — `a?.b` → `a!!.b` (drop null guard)
- `EmptyCollectionReturnOperator` — collection return → `emptyList()`/`emptySet()`/`emptyMap()`
- `AssignConstOperator` — `a = b` → `a = <default const>` (numeric 0, `""`, `false`, `null`)
- Recursive operator application: multiple operators can match the same expression
- Type-agnostic: works with `Int`, `Long`, `Double`, `Float`, etc.
- Respects `@SuppressMutations` annotation on classes and functions
Expand All @@ -890,6 +963,10 @@ Code only reached outside `MutFlow.underTest { }` blocks produces no mutations.
- Target filtering: `includeTargets`/`excludeTargets` for scoping mutations by class
- `MutationsExhaustedException` when all mutations tested
- `VerificationMode` enum: `STRICT`, `LENIENT`, `DISABLED`
- **Multiplatform**: lives in `commonMain`; uses `kotlin.uuid.Uuid` for session IDs and
`TimeSource.Monotonic` for seeds. Thread routing and the concurrent map are expect/actual
(real `Thread.currentThread().id` + `ConcurrentHashMap` on JVM; constants/plain maps on
JS/WASM/Native).

**mutflow-junit6:**
- `@MutFlowTest` meta-annotation combining `@ClassTemplate` + `@ExtendWith`
Expand All @@ -901,6 +978,17 @@ Code only reached outside `MutFlow.underTest { }` blocks produces no mutations.
**mutflow-test-sample:**
- Integration tests demonstrating both APIs

**mutflow-test-kmp (multiplatform sample):**
- Same `CalculatorTest` runs on JVM, JS (IR), WASM, and Native (linuxX64)
- Verifies cross-backend consistency of mutation points and killing
- `mutflow-core` provides expect/actual for concurrent collections and the session lock
(`jvmMain` real `synchronized`, `jsMain`/`wasmJsMain`/`nativeMain` no-op)

**mutflow-gradle-plugin:**
- JVM: dual-compilation via a dedicated `mutatedMain` source set
- KMP: applies the compiler plugin to every compilation and wires the runtime into
`commonMain`/`commonTest`; injection gated on `@MutationTarget` keeps production clean

### Target API

```kotlin
Expand Down Expand Up @@ -977,9 +1065,10 @@ fun isPositive(x: Int) = when (MutationRegistry.check("..._0", 2, "Calculator.kt

### Planned

- Gradle plugin for easy setup
- Smarter likelihood calculations (see below)
- State invalidation hooks
- Real mutex for Native `withSessionLock` (currently sequential no-op)
- WASM/WASI target (beyond `wasmJs`)

#### Smarter Likelihood Calculations

Expand Down
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- [How Mutations Work](#how-mutations-work)
- [Design Decisions](#design-decisions)
- [Troubleshooting](#troubleshooting)
- [Migration to Multiplatform](docs/migration-notes.md)

## What is this?

Expand Down Expand Up @@ -130,6 +131,56 @@ mutflow.enabled=false

When disabled, your code still compiles normally (`@MutationTarget` and `@MutFlowTest` annotations are still available), but tests run without any mutations - the mutation summary will show 0 mutations discovered.

### Multiplatform (KMP) Support

mutflow's mutation engine is **target-agnostic**: the same operators and mutation points
work on JVM, Kotlin/JS, Kotlin/WASM, and Kotlin/Native. The compiler plugin injects the
same guarded `when`/conditionals on every backend, and the runtime (`mutflow-core`) lives
in `commonMain` with only trivial expect/actual for concurrency.

To use mutflow in a KMP project, apply the plugin and add the runtime to the common source
sets:

```kotlin
plugins {
kotlin("multiplatform") version "2.4.0"
id("io.github.anschnapp.mutflow") version "<latest-version>"
}

kotlin {
jvm()
js(IR) { nodejs() }
wasmJs { nodejs() }
linuxX64()

sourceSets {
commonMain.dependencies {
implementation("io.github.anschnapp.mutflow:mutflow-annotations:<version>")
implementation("io.github.anschnapp.mutflow:mutflow-core:<version>")
}
commonTest.dependencies {
implementation("io.github.anschnapp.mutflow:mutflow-core:<version>")
implementation(kotlin("test"))
}
}
}
```

> If you use the Gradle plugin, the `commonMain`/`commonTest` runtime wiring is done for
> you automatically.

**Test runner per platform:**

- **JVM**: use `@MutFlowTest` / `MutFlow.underTest` with JUnit 6 (`mutflow-junit6`).
- **JS / WASM / Native**: use `kotlin.test` (`@Test`). The `MutationRegistry` session API
(`withSession`, `startSession`/`endSession`) is multiplatform and works identically.

**Production safety on KMP:** the plugin is applied to every compilation, but injection is
gated on `@MutationTarget` (or configured target patterns) — production code is untouched
unless explicitly annotated. See [docs/migration-notes.md](docs/migration-notes.md) for
full migration guidance, and the `mutflow-test-kmp` sample for a working multi-target
project.

## Quick Start

```kotlin
Expand Down Expand Up @@ -432,9 +483,27 @@ The script requires `bash` and `unzip`. It is tested end-to-end by `scripts/test
- [**Boolean return**](#how-boolean-return-mutations-work) - Boolean return values replaced with `true`/`false` (explicit returns only)
- [**Nullable return**](#how-nullable-return-mutations-work) - Nullable return values replaced with `null` (explicit returns only)
- [**Void function body**](#how-void-function-body-mutations-work) - Unit function bodies replaced with empty bodies, detecting untested side effects
- **Bitwise** - `and` ↔ `or`, `xor` → `and`/`or`, `shl` ↔ `shr`, `ushr` → `shl` (integer types)
- **Unary minus** - `-a` → `a`
- **Increment** - `++` ↔ `--` (and experimental `a++` → `a`)
- **Replace non-void call** - non-void call → default value of its return type
- **Primitive return** - numeric return → `0`; **Object return** - object return → `null`
- **Boolean const** - `true` ↔ `false`; **String literal** - string → `""`
- **Constructor call** - `Foo()` → `null`
- **Force conditional** - `if (cond)` → `true` / `false`
- **String methods** - `endsWith` ↔ `startsWith`, `toUpperCase` ↔ `toLowerCase`, `trim` → `""`
- **Collection methods** - `filter` ↔ `filterNot`, `any` ↔ `all`, `take` ↔ `drop`, `isEmpty` ↔ `isNotEmpty`, `min` ↔ `max`, `minBy` ↔ `maxBy`
- **Reference equality** - `===` ↔ `!==`
- **Elvis** - `a ?: b` → `a` / `b`
- **Safe call** - `a?.b` → `a!!.b` (drop the null guard)
- **Empty collection return** - `return listOf(...)` → `return emptyList()` (also emptySet/emptyMap)
- **Assign const** - `a = b` → `a = <default constant>` (0, `""`, `false`, or `null`)
- **Recursive operator nesting** - Multiple mutation types combine on the same expression
- **Type-agnostic** - Works with `Int`, `Long`, `Double`, `Float`, `Short`, `Byte`, `Char`

> The full catalog, including per-backend (JVM/JS/WASM/Native) lowering forms and
> experimental operators, is documented in [docs/mutation-catalog.md](docs/mutation-catalog.md).

## Features

**Core**
Expand Down
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2g
# Native targets (linuxX64, macosArm64) are cross-declared but only one is ever
# buildable/runnable on a given host; don't warn about the other being disabled.
kotlin.native.ignoreDisabledTargets=true

kotlinVersion=2.4.0
junitVersion=6.1.1
Loading