From 7446b6329a2c5944eb166267f949cdc185da4186 Mon Sep 17 00:00:00 2001 From: anschnapp Date: Sat, 4 Jul 2026 10:59:11 +0200 Subject: [PATCH] chore: avoid mutation for null checks, this was especially confusing for all the syntax sugar derived null checks --- CHANGELOG.md | 4 ++ DESIGN.md | 1 + .../mutflow/compiler/EqualitySwapOperator.kt | 29 +++++++++-- .../main/kotlin/sample/NullSafetyTarget.kt | 28 ++++++++++ .../kotlin/sample/NullSafetyTargetTest.kt | 51 +++++++++++++++++++ 5 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 mutflow-test-sample/src/main/kotlin/sample/NullSafetyTarget.kt create mode 100644 mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8695516..67c9392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index 773e894..ea08fe1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EqualitySwapOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EqualitySwapOperator.kt index 27d7783..c70f5ec 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EqualitySwapOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EqualitySwapOperator.kt @@ -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 @@ -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 -> "==" diff --git a/mutflow-test-sample/src/main/kotlin/sample/NullSafetyTarget.kt b/mutflow-test-sample/src/main/kotlin/sample/NullSafetyTarget.kt new file mode 100644 index 0000000..fa70964 --- /dev/null +++ b/mutflow-test-sample/src/main/kotlin/sample/NullSafetyTarget.kt @@ -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!! +} diff --git a/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt b/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt new file mode 100644 index 0000000..f31f06f --- /dev/null +++ b/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt @@ -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}" } + ) + } +}