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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [1.0.3] - 2026-07-04
### Fixed
- Equality swap operator no longer mutates null comparisons. Kotlin's null-safety operators (`?:`, `?.`) desugar to a synthesized `x == null` check, which previously produced confusing `== -> !=` mutations on code with no visible equality operator (and, for safe-calls, an always-crashing mutant). Explicit `x == null` / `x != null` are skipped too, since inverting a null check is typically an equivalent mutant with little signal.

## [1.0.0] - 2026-04-01

mutflow's first stable release. The public API (`@MutFlowTest`, `MutFlow.underTest {}`, `@MutationTarget`, Gradle DSL) is now considered stable.
Expand Down
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,7 @@ Code only reached outside `MutFlow.underTest { }` blocks produces no mutations.
- In K2 IR, `==` is a single EQEQ intrinsic; `!=` is `not(EQEQ(a, b))` - two calls both with EXCLEQ origin
- Matches EQEQ calls with EQEQ origin for `==`, and `not()` calls with EXCLEQ origin for `!=`
- Avoids double-matching the inner EQEQ of `!=` expressions (which would create spurious mutation points)
- Skips null comparisons (either operand is the `null` literal). Kotlin's null-safety operators (`?:`, `?.`) desugar to a synthesized `x == null` check in IR; mutating it would be a misleading `== → !=` on code with no visible equality operator, and for safe-calls an always-crash mutant. Explicit `x == null` / `x != null` are also skipped since inverting a null check is usually an equivalent mutant or a downstream NPE (`a!!` never matched - it lowers via a `checkNotNull` intrinsic, not EQEQ)
- `BooleanInversionOperator` adds negation to boolean expressions
- `expr` → `!expr` (1 variant: wraps in `Boolean.not()`)
- Matches boolean-returning `IrCall` nodes with null or `GET_PROPERTY` origin (function calls and property accesses)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.github.anschnapp.mutflow.compiler

import org.jetbrains.kotlin.ir.builders.irCall
import org.jetbrains.kotlin.ir.expressions.IrCall
import org.jetbrains.kotlin.ir.expressions.IrConst
import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin
import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols
Expand All @@ -21,22 +22,42 @@ import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols
*
* Important: We match the outer `not()` call for `!=`, NOT the inner EQEQ.
* Matching the inner EQEQ would create a duplicate/spurious mutation point.
*
* Null comparisons are deliberately skipped. Kotlin's null-safety operators
* (`?:`, `?.`) desugar to a compiler-synthesized `x == null` check in IR, so
* without this guard we would mint `== ↔ !=` mutations on code where the
* developer never wrote an equality operator - a misleading display and, for
* safe-calls, an always-crash mutant. We also skip explicit `x == null` /
* `x != null`: inverting a null check is typically an equivalent mutant or
* produces a downstream NPE, so it carries little signal.
*/
@OptIn(UnsafeDuringIrConstructionAPI::class)
class EqualitySwapOperator : MutationOperator {

override fun matches(call: IrCall): Boolean {
return when {
// == : EQEQ intrinsic with EQEQ origin
// == : EQEQ intrinsic with EQEQ origin (but not a null comparison)
call.origin == IrStatementOrigin.EQEQ
&& call.symbol.owner.name.asString() == "EQEQ" -> true
// != : outer not() wrapper with EXCLEQ origin
&& call.symbol.owner.name.asString() == "EQEQ" -> !isNullComparison(call)
// != : outer not() wrapper with EXCLEQ origin (but not a null comparison)
call.origin == IrStatementOrigin.EXCLEQ
&& call.symbol.owner.name.asString() == "not" -> true
&& call.symbol.owner.name.asString() == "not" -> {
val innerEqEq = call.dispatchReceiver as? IrCall
innerEqEq != null && !isNullComparison(innerEqEq)
}
else -> false
}
}

/**
* True if the EQEQ call has a `null` literal as one of its operands
* (i.e. an `x == null` / `null == x` comparison, whether hand-written or
* synthesized by a null-safety operator).
*/
private fun isNullComparison(eqeqCall: IrCall): Boolean {
return eqeqCall.arguments.any { it is IrConst && it.value == null }
}

override fun originalDescription(call: IrCall): String {
return when (call.origin) {
IrStatementOrigin.EQEQ -> "=="
Expand Down
28 changes: 28 additions & 0 deletions mutflow-test-sample/src/main/kotlin/sample/NullSafetyTarget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package sample

import io.github.anschnapp.mutflow.MutationTarget

/**
* Target class exercising Kotlin null-safety operators (`?:`, `?.`, `!!`).
*
* These operators desugar to a compiler-synthesized `x == null` check in IR.
* mutflow must NOT create `== ↔ !=` mutations for these synthetic null checks:
* the developer never wrote an equality operator, the display would be
* misleading, and inverting the internal null check is either an always-crash
* mutant (safe-call, `!!`) or confusing noise (elvis). See EqualitySwapOperator.
*
* Each function below contains ONLY a null-safety construct, so a correctly
* behaving plugin discovers zero mutation points for this whole class.
*/
@MutationTarget
class NullSafetyTarget {

/** Elvis: desugars to `when { a == null -> fallback; else -> a }`. */
fun elvis(a: Int?, fallback: Int): Int = a ?: fallback

/** Safe call: desugars to `when { s == null -> null; else -> s.length }`. */
fun safeLength(s: String?): Int? = s?.length

/** Not-null assertion: desugars to `when { a == null -> throw NPE; else -> a }`. */
fun bang(a: Int?): Int = a!!
}
51 changes: 51 additions & 0 deletions mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package sample

import io.github.anschnapp.mutflow.MutFlow
import io.github.anschnapp.mutflow.MutationRegistry
import io.github.anschnapp.mutflow.Selection
import io.github.anschnapp.mutflow.Shuffle
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue

/**
* Regression test: Kotlin null-safety operators (`?:`, `?.`, `!!`) must produce
* NO mutation points.
*
* These operators desugar to a compiler-synthesized `x == null` check in IR.
* EqualitySwapOperator skips null comparisons, so exercising a class that
* contains only null-safety constructs must discover zero mutation points.
* Before the fix, `?:` and `?.` each produced a spurious `== → !=` point.
*/
class NullSafetyTargetTest {

private val target = NullSafetyTarget()

@BeforeTest
fun setup() {
MutationRegistry.reset()
MutFlow.reset()
}

@Test
fun `null-safety operators produce no mutation points`() {
MutFlow.underTest(run = 0, selection = Selection.MostLikelyStable, shuffle = Shuffle.PerChange) {
target.elvis(null, 7)
}
MutFlow.underTest(run = 0, selection = Selection.MostLikelyStable, shuffle = Shuffle.PerChange) {
target.safeLength("abc")
}
MutFlow.underTest(run = 0, selection = Selection.MostLikelyStable, shuffle = Shuffle.PerChange) {
target.bang(42)
}

val points = MutFlow.getRegistryState().discoveredPoints
.filter { it.key.contains("NullSafetyTarget") }

assertTrue(
points.isEmpty(),
"Null-safety operators must not produce mutation points, but found: " +
points.entries.joinToString { "${it.key}=${it.value}" }
)
}
}