diff --git a/.gitignore b/.gitignore index 8c7bf32..f5d8817 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/DESIGN.md b/DESIGN.md index ea08fe1..b03ee0a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: @@ -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 = ` (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 @@ -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` @@ -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 @@ -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 diff --git a/README.md b/README.md index 5f7dd6f..3250a70 100644 --- a/README.md +++ b/README.md @@ -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? @@ -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 "" +} + +kotlin { + jvm() + js(IR) { nodejs() } + wasmJs { nodejs() } + linuxX64() + + sourceSets { + commonMain.dependencies { + implementation("io.github.anschnapp.mutflow:mutflow-annotations:") + implementation("io.github.anschnapp.mutflow:mutflow-core:") + } + commonTest.dependencies { + implementation("io.github.anschnapp.mutflow:mutflow-core:") + 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 @@ -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 = ` (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** diff --git a/gradle.properties b/gradle.properties index d7c6df5..d4e697c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock new file mode 100644 index 0000000..de12e22 --- /dev/null +++ b/kotlin-js-store/yarn.lock @@ -0,0 +1,571 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^2.0.2: + version "2.1.4" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.4.tgz#589dab11c0018d0366be64cd8bf12c8dbecc8326" + integrity sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg== + dependencies: + balanced-match "^1.0.0" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +format-util@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" + integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +glob@^10.4.5: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-yaml@^4.1.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848" + integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ== + dependencies: + argparse "^2.0.1" + +kotlin-web-helpers@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-3.0.0.tgz#3ed6b48f694f74bb60a737a9d7e2c0e3b29abdb9" + integrity sha512-kdQO4AJQkUPvpLh9aglkXDRyN+CfXO7pKq+GESEnxooBFkQpytLrqZis3ABvmFN1cGw/ZQ/K38u5sRGW+NfBnw== + dependencies: + format-util "^1.0.5" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +mocha@11.7.5: + version "11.7.5" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.5.tgz#58f5bbfa5e0211ce7e5ee6128107cefc2515a627" + integrity sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== + dependencies: + browser-stdout "^1.3.1" + chokidar "^4.0.1" + debug "^4.3.5" + diff "^7.0.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^10.4.5" + he "^1.2.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^9.0.5" + ms "^2.1.3" + picocolors "^1.1.1" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^9.2.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + yargs-unparser "^2.0.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +source-map-support@0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +workerpool@^9.2.0: + version "9.3.4" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" + integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^17.7.2: + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/mutflow-annotations/build.gradle.kts b/mutflow-annotations/build.gradle.kts index e70c956..75a558b 100644 --- a/mutflow-annotations/build.gradle.kts +++ b/mutflow-annotations/build.gradle.kts @@ -1,10 +1,24 @@ plugins { - kotlin("jvm") + kotlin("multiplatform") id("com.vanniktech.maven.publish") } -dependencies { - testImplementation(kotlin("test")) +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) +kotlin { + jvm() + js { + nodejs() + } + wasmJs { + nodejs() + } + linuxX64() + macosArm64() + + sourceSets { + commonMain.dependencies { + } + } } mavenPublishing { diff --git a/mutflow-annotations/src/main/kotlin/io/github/anschnapp/mutflow/MutationTarget.kt b/mutflow-annotations/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutationTarget.kt similarity index 100% rename from mutflow-annotations/src/main/kotlin/io/github/anschnapp/mutflow/MutationTarget.kt rename to mutflow-annotations/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutationTarget.kt diff --git a/mutflow-annotations/src/main/kotlin/io/github/anschnapp/mutflow/SuppressMutations.kt b/mutflow-annotations/src/commonMain/kotlin/io/github/anschnapp/mutflow/SuppressMutations.kt similarity index 100% rename from mutflow-annotations/src/main/kotlin/io/github/anschnapp/mutflow/SuppressMutations.kt rename to mutflow-annotations/src/commonMain/kotlin/io/github/anschnapp/mutflow/SuppressMutations.kt diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArgumentPropagationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArgumentPropagationOperator.kt new file mode 100644 index 0000000..7839265 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArgumentPropagationOperator.kt @@ -0,0 +1,88 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator that propagates a call argument into another argument slot. + * + * Mirrors pitest's `ARGUMENT_PROPAGATION` (experimental). For a call `f(a, b)` + * the variants replace one argument with a copy of another of the same type: + * - `f(a, b)` → `f(a, a)` (propagate the first value arg into the second) + * - `f(a, b)` → `f(b, b)` (propagate the second value arg into the first) + * + * Scoped to regular method/function calls (`origin == null`) so operator calls + * (`a + b`, `a > b`, ...) are left to their dedicated operators. Requires at + * least two value arguments whose types are equal, so the replacement typechecks + * on every backend. + * + * Argument layout: `call.arguments` is positionally aligned with + * `call.symbol.owner.parameters`, so dispatch receiver, context parameters, + * and extension receiver slots (in any combination) are identified by kind + * and excluded from the propagable value args. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ArgumentPropagationOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "ARGUMENT_PROPAGATION", + name = "ArgumentPropagation", + description = "Propagate one argument into another (f(a,b) → f(a,a) / f(b,b))", + group = MutatorGroup.CALL, + status = MutatorStatus.EXPERIMENTAL + ) + + override fun matches(call: IrCall): Boolean { + // Only regular calls (operators and property getters have non-null origins). + if (call.origin != null) return false + val valueArgs = valueArguments(call) + return valueArgs.size >= 2 && sameType(valueArgs[0].first, valueArgs[1].first) + } + + override fun originalDescription(call: IrCall): String = + call.symbol.owner.name.asString() + + /** + * Returns the value arguments (excluding dispatch receiver, context + * parameters, and extension receiver, regardless of which combination is + * present) as (expression, argumentIndex) pairs. `call.arguments` is + * positionally aligned with `call.symbol.owner.parameters`, so each slot's + * kind is looked up directly rather than inferred from a single offset. + */ + private fun valueArguments(call: IrCall): List> { + val kinds = call.symbol.owner.parameters.map { it.kind } + return call.arguments.mapIndexedNotNull { i, expr -> + if (expr != null && kinds.getOrNull(i) == IrParameterKind.Regular) expr to i else null + } + } + + /** Structural type equality via the class FQN (IrType `==` is unreliable across backends). */ + private fun sameType(a: IrExpression, b: IrExpression): Boolean = + a.type.classFqName == b.type.classFqName + + override fun variants(call: IrCall, context: MutationContext): List { + val valueArgs = valueArguments(call) + if (valueArgs.size < 2) return emptyList() + val (arg0, idx0) = valueArgs[0] + val (arg1, idx1) = valueArgs[1] + if (!sameType(arg0, arg1)) return emptyList() + + // f(a, b) → f(a, a): copy arg0 into slot 1. + val propagateFirst = MutationOperator.Variant("${call.symbol.owner.name.asString()}(arg->${idx1})") { + val newCall = call.deepCopyWithSymbols() + newCall.arguments[idx1] = arg0.deepCopyWithSymbols() + newCall + } + // f(a, b) → f(b, b): copy arg1 into slot 0. + val propagateSecond = MutationOperator.Variant("${call.symbol.owner.name.asString()}(arg->${idx0})") { + val newCall = call.deepCopyWithSymbols() + newCall.arguments[idx0] = arg1.deepCopyWithSymbols() + newCall + } + return listOf(propagateFirst, propagateSecond) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArithmeticOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArithmeticOperator.kt index 8c6ca71..f926e0b 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArithmeticOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ArithmeticOperator.kt @@ -2,6 +2,7 @@ package io.github.anschnapp.mutflow.compiler import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET import org.jetbrains.kotlin.ir.builders.* +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.expressions.IrCall import org.jetbrains.kotlin.ir.expressions.IrExpression import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin @@ -37,6 +38,14 @@ import org.jetbrains.kotlin.name.Name @OptIn(UnsafeDuringIrConstructionAPI::class) class ArithmeticOperator : MutationOperator { + override val descriptor = MutatorDescriptor( + id = "ARITHMETIC_SWAP", + name = "ArithmeticSwap", + description = "Swap +↔-, *↔/ (safe division), %→/", + group = MutatorGroup.ARITHMETIC, + status = MutatorStatus.STABLE + ) + companion object { private val SUPPORTED_ORIGINS = setOf( IrStatementOrigin.PLUS, @@ -89,9 +98,21 @@ class ArithmeticOperator : MutationOperator { fun findFunction(name: String): IrSimpleFunctionSymbol? { if (name == originalFunctionName) return originalSymbol // same function val callableId = CallableId(declaringClassId, Name.identifier(name)) - // For primitives, just get the first function - the type checker - // already resolved the correct overload for the original call - return context.pluginContext.referenceFunctions(callableId).firstOrNull() + // For primitives, pick the overload whose parameter type matches the + // original call's second parameter type. `referenceFunctions(...).firstOrNull()` + // is not reliable here: e.g. `Int.minus` has overloads for Byte/Short/Int/ + // Long/Float/Double, and the first one may be `minus(other: Byte)`, which + // produces a ClassCastException on Native. Match the actual operand type. + val originalParamType = originalSymbol.owner.parameters.getOrNull(1)?.type + return if (originalParamType != null) { + context.pluginContext.referenceFunctions(callableId) + .firstOrNull { fn -> + fn.owner.parameters.getOrNull(1)?.type == originalParamType + } + ?: context.pluginContext.referenceFunctions(callableId).firstOrNull() + } else { + context.pluginContext.referenceFunctions(callableId).firstOrNull() + } } val plusFn = findFunction("plus") @@ -150,7 +171,7 @@ class ArithmeticOperator : MutationOperator { ) } IrStatementOrigin.PERC -> { - // % → / + // % → / and % → * listOfNotNull( divFn?.let { fn -> MutationOperator.Variant("/") { @@ -159,6 +180,14 @@ class ArithmeticOperator : MutationOperator { it.arguments[1] = right.deepCopyWithSymbols() } } + }, + timesFn?.let { fn -> + MutationOperator.Variant("*") { + context.builder.irCall(fn).also { + it.arguments[0] = left.deepCopyWithSymbols() + it.arguments[1] = right.deepCopyWithSymbols() + } + } } ) } diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/AssignConstOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/AssignConstOperator.kt new file mode 100644 index 0000000..4bb4a9a --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/AssignConstOperator.kt @@ -0,0 +1,68 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.* + +/** + * Swaps the right-hand side of an assignment for a default constant. + * + * Roughly Mull's `cxx_assign_const`/`cxx_init_const`: `var a = x` becomes + * `var a = 0`, `field = s` becomes `field = ""`, etc. Catches tests that don't + * actually check what got stored. The constant follows the assigned type + * (numeric 0, `'a'`, `""`, `false`, or `null`). Assignments that already hold + * a constant are skipped — no point turning `0` into `0`. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class AssignConstOperator : AssignmentMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "ASSIGN_CONST", + name = "AssignConst", + description = "Replace assigned value with default constant", + group = MutatorGroup.ARITHMETIC, + status = MutatorStatus.STABLE + ) + + override fun matches(targetType: IrType, assignedValue: IrExpression): Boolean = + assignedValue !is IrConst + + override fun originalDescription(assignedValue: IrExpression): String = "=" + + override fun variants( + targetType: IrType, + assignedValue: IrExpression, + context: MutationContext + ): List { + val defaultValue = defaultForType(targetType, assignedValue.startOffset, assignedValue.endOffset) + ?: return emptyList() + + return listOf( + MutationOperator.Variant(describeValue(defaultValue)) { + defaultValue + } + ) + } + + private fun describeValue(value: IrExpression): String = when (value) { + is IrConst -> value.value.toString() + else -> "null" + } + + private fun defaultForType(type: IrType, startOffset: Int, endOffset: Int): IrExpression? { + return when { + type.isInt() -> IrConstImpl.int(startOffset, endOffset, type, 0) + type.isLong() -> IrConstImpl.long(startOffset, endOffset, type, 0L) + type.isShort() -> IrConstImpl.short(startOffset, endOffset, type, 0) + type.isByte() -> IrConstImpl.byte(startOffset, endOffset, type, 0) + type.isFloat() -> IrConstImpl.float(startOffset, endOffset, type, 0.0f) + type.isDouble() -> IrConstImpl.double(startOffset, endOffset, type, 0.0) + type.isChar() -> IrConstImpl.char(startOffset, endOffset, type, 'a') + type.isBoolean() -> IrConstImpl.boolean(startOffset, endOffset, type, false) + type.isString() -> IrConstImpl.string(startOffset, endOffset, type, "") + else -> IrConstImpl.constNull(startOffset, endOffset, type.makeNullable()) + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/AssignmentMutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/AssignmentMutationOperator.kt new file mode 100644 index 0000000..3dda753 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/AssignmentMutationOperator.kt @@ -0,0 +1,28 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.types.IrType + +/** + * Mutation operators that target assignment nodes — [org.jetbrains.kotlin.ir.expressions.IrSetValue] + * and [org.jetbrains.kotlin.ir.expressions.IrSetField] — which are distinct IR + * node types from [IrCall], so they get their own visitor path in the transformer. + */ +interface AssignmentMutationOperator { + + /** Declarative metadata for this operator (stable id, group, status). */ + val descriptor: MutatorDescriptor + + /** Whether this operator can mutate an assignment to [targetType] of [assignedValue]. */ + fun matches(targetType: IrType, assignedValue: IrExpression): Boolean + + /** Generates mutation variants for the given assignment. */ + fun variants( + targetType: IrType, + assignedValue: IrExpression, + context: MutationContext + ): List + + /** Returns a description of the original assignment for display. */ + fun originalDescription(assignedValue: IrExpression): String +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BitwiseOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BitwiseOperator.kt new file mode 100644 index 0000000..63af73f --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BitwiseOperator.kt @@ -0,0 +1,127 @@ +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.IrExpression +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.* +import org.jetbrains.kotlin.ir.util.classId +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.Name + +/** + * Mutation operator for bitwise operations on integer types. + * + * Mirrors pitest's `MATH` bitwise substitutions and Mull's `cxx_bitwise` group: + * - `and` ↔ `or` + * - `xor` → `and` + * - `shl` ↔ `shr` + * - `ushr` → `shl` + * + * In Kotlin IR these are infix function calls (`Int.and`, `Long.shl`, ...) with + * no dedicated [org.jetbrains.kotlin.ir.expressions.IrStatementOrigin], so they + * are matched by function name and receiver type. Boolean `and`/`or`/`xor` + * (non-short-circuit logical operators) are deliberately excluded by requiring + * an integer receiver type. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class BitwiseOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "BITWISE_SWAP", + name = "BitwiseSwap", + description = "Swap and↔or, xor→and, shl↔shr, ushr→shl", + group = MutatorGroup.ARITHMETIC, + status = MutatorStatus.STABLE + ) + + private companion object { + private val BITWISE_NAMES = setOf("and", "or", "xor", "shl", "shr", "ushr") + + private val REPLACEMENTS = mapOf( + "and" to "or", + "or" to "and", + "xor" to "and", + "shl" to "shr", + "shr" to "shl", + "ushr" to "shl" + ) + + // Additional variants for a given name, produced alongside REPLACEMENTS. + // xor → or (Mull cxx_xor_to_or) in addition to xor → and (pitest). + private val ADDITIONAL_REPLACEMENTS = mapOf( + "xor" to listOf("or") + ) + } + + override fun matches(call: IrCall): Boolean { + val name = call.symbol.owner.name.asString() + if (name !in BITWISE_NAMES) return false + // Only integer bitwise ops — exclude Boolean and/or/xor (logical, non-short-circuit) + val receiverType = call.dispatchReceiver?.type ?: return false + return receiverType.isInt() || receiverType.isLong() || receiverType.isShort() || + receiverType.isByte() || receiverType.isUInt() || receiverType.isULong() + } + + override fun originalDescription(call: IrCall): String = call.symbol.owner.name.asString() + + override fun variants(call: IrCall, context: MutationContext): List { + val name = call.symbol.owner.name.asString() + val replacementName = REPLACEMENTS[name] ?: return emptyList() + + val left = call.dispatchReceiver ?: return emptyList() + val right = call.arguments.getOrNull(0) ?: return emptyList() + + val replacementFn = findFunction(call, replacementName, context) ?: return emptyList() + + val variants = mutableListOf( + MutationOperator.Variant(replacementName) { + context.builder.irCall(replacementFn).also { + it.dispatchReceiver = left.deepCopyWithSymbols() + it.arguments[0] = right.deepCopyWithSymbols() + } + } + ) + + // Additional variants (e.g. xor → or alongside xor → and). + for (additional in ADDITIONAL_REPLACEMENTS[name].orEmpty()) { + val fn = findFunction(call, additional, context) ?: continue + variants += MutationOperator.Variant(additional) { + context.builder.irCall(fn).also { + it.dispatchReceiver = left.deepCopyWithSymbols() + it.arguments[0] = right.deepCopyWithSymbols() + } + } + } + + return variants + } + + /** + * Finds the replacement function with the same signature as the original. + * Matched by the original call's operand type (mirrors ArithmeticOperator's + * overload matching): a class like `Int` has a single `and(Int)` overload, + * but relying on `referenceFunctions(...).firstOrNull()` alone is fragile if + * that ever changes, so pick the overload whose value parameter type equals + * the original's. + */ + private fun findFunction( + original: IrCall, + replacementName: String, + context: MutationContext + ): IrSimpleFunctionSymbol? { + val declaringClassId = (original.symbol.owner.parent as? org.jetbrains.kotlin.ir.declarations.IrClass)?.classId + ?: return null + val callableId = CallableId(declaringClassId, Name.identifier(replacementName)) + val candidates = context.pluginContext.referenceFunctions(callableId) + val originalParamType = original.symbol.owner.parameters.getOrNull(1)?.type + return if (originalParamType != null) { + candidates.firstOrNull { fn -> fn.owner.parameters.getOrNull(1)?.type == originalParamType } + ?: candidates.firstOrNull() + } else { + candidates.firstOrNull() + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanConstOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanConstOperator.kt new file mode 100644 index 0000000..1025531 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanConstOperator.kt @@ -0,0 +1,51 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isBoolean + +/** + * Mutation operator for boolean constant flips: `true` ↔ `false`. + * + * Replaces a boolean literal with its negation to detect tests that don't + * verify the actual boolean value. Mirrors pitest's `INVERT_NEGS` for boolean + * constants and Stryker's `BooleanLiteral` mutator. + * + * Note: boolean *returns* are handled by [BooleanReturnOperator]; this operator + * targets boolean literals anywhere else (initializers, arguments, conditions). + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class BooleanConstOperator : ConstMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "BOOLEAN_CONST", + name = "BooleanConst", + description = "Flip boolean literal true ↔ false", + group = MutatorGroup.BOOLEAN, + status = MutatorStatus.STABLE + ) + + override fun matches(const: IrConst): Boolean = + const.type.isBoolean() && const.value is Boolean + + override fun originalDescription(const: IrConst): String = + const.value.toString() + + override fun variants(const: IrConst, context: MutationContext): List { + val value = const.value as? Boolean ?: return emptyList() + val flipped = !value + val booleanType = context.pluginContext.irBuiltIns.booleanType + + return listOf( + MutationOperator.Variant(flipped.toString()) { + IrConstImpl.boolean( + const.startOffset, + const.endOffset, + booleanType, + flipped + ) + } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanInversionOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanInversionOperator.kt index 0a348c8..e5a7f6e 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanInversionOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanInversionOperator.kt @@ -22,6 +22,14 @@ import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols @OptIn(UnsafeDuringIrConstructionAPI::class) class BooleanInversionOperator : MutationOperator { + override val descriptor = MutatorDescriptor( + id = "BOOLEAN_INVERSION", + name = "BooleanInversion", + description = "Invert boolean expression: expr → !expr", + group = MutatorGroup.BOOLEAN, + status = MutatorStatus.STABLE + ) + override fun matches(call: IrCall): Boolean { val name = call.symbol.owner.name.asString() if (name == "not") return false diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanLogicOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanLogicOperator.kt index 3505c9c..00fe116 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanLogicOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanLogicOperator.kt @@ -1,5 +1,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.IrStatementOrigin import org.jetbrains.kotlin.ir.expressions.IrWhen import org.jetbrains.kotlin.ir.expressions.impl.IrBranchImpl @@ -10,31 +12,73 @@ import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols /** - * Mutation operator for boolean logic swaps: && ↔ || + * Mutation operator for boolean logic swaps: `&&` ↔ `||`. * - * In Kotlin K2 IR (2.3.0+), boolean operators are lowered to IrWhen expressions: - * - `a && b` → IrWhen(origin=ANDAND): when { a -> b; else -> false } - * - `a || b` → IrWhen(origin=OROR): when { a -> true; else -> b } + * Handles BOTH IR representations of `&&`/`||`: + * - **K2 IR (full Gradle pipeline)**: lowered to `IrWhen` with origin + * [IrStatementOrigin.ANDAND] / [IrStatementOrigin.OROR]: + * `a && b` → `when { a -> b; else -> false }` + * - **Intrinsic-call form (isolated CLI compilation)**: an `IrCall` to the + * `ANDAND` / `OROR` intrinsic with both operands as value arguments. * - * Mutation approach (swap branch results): - * - && → ||: change result from b to true, change else from false to b - * - || → &&: change result from true to b, change else from b to false + * The operator is registered in both the call and when operator lists; only one + * form is present in any given compilation, so exactly one mutation point is + * generated per `&&`/`||`. + * + * Mirrors pitest's `NEGATE_CONDITIONALS` for boolean logic and Stryker's + * `LogicalOperator` mutator. */ @OptIn(UnsafeDuringIrConstructionAPI::class) -class BooleanLogicOperator : WhenMutationOperator { +class BooleanLogicOperator : MutationOperator, WhenMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "BOOLEAN_LOGIC", + name = "BooleanLogic", + description = "Swap && ↔ ||", + group = MutatorGroup.BOOLEAN, + status = MutatorStatus.STABLE + ) - override fun matches(whenExpr: IrWhen): Boolean { - return whenExpr.origin == IrStatementOrigin.ANDAND || - whenExpr.origin == IrStatementOrigin.OROR + // --- IrCall form (intrinsic call: ANDAND / OROR) --- + + override fun matches(call: IrCall): Boolean { + val name = call.symbol.owner.name.asString() + return name == "ANDAND" || name == "OROR" } - override fun originalDescription(whenExpr: IrWhen): String { - return when (whenExpr.origin) { + override fun originalDescription(call: IrCall): String = + if (call.symbol.owner.name.asString() == "ANDAND") "&&" else "||" + + override fun variants(call: IrCall, context: MutationContext): List { + val builtIns = context.pluginContext.irBuiltIns + val isAnd = call.symbol.owner.name.asString() == "ANDAND" + val replacementSymbol = if (isAnd) builtIns.ororSymbol else builtIns.andandSymbol + val description = if (isAnd) "||" else "&&" + + return listOf( + MutationOperator.Variant(description) { + context.builder.irCall(replacementSymbol).also { newCall -> + call.arguments.forEachIndexed { index, arg -> + if (arg != null) { + newCall.arguments[index] = arg.deepCopyWithSymbols() + } + } + } + } + ) + } + + // --- IrWhen form (K2 lowering: when { a -> b; else -> false }) --- + + override fun matches(whenExpr: IrWhen): Boolean = + whenExpr.origin == IrStatementOrigin.ANDAND || whenExpr.origin == IrStatementOrigin.OROR + + override fun originalDescription(whenExpr: IrWhen): String = + when (whenExpr.origin) { IrStatementOrigin.ANDAND -> "&&" IrStatementOrigin.OROR -> "||" else -> "?" } - } override fun variants(whenExpr: IrWhen, context: MutationContext): List { // Validate structure: expect exactly 2 branches (condition + else) diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanReturnOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanReturnOperator.kt index e568b29..f2e54c8 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanReturnOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/BooleanReturnOperator.kt @@ -29,6 +29,14 @@ import org.jetbrains.kotlin.ir.types.isBoolean */ class BooleanReturnOperator : ReturnMutationOperator { + override val descriptor = MutatorDescriptor( + id = "RETURN_BOOLEAN", + name = "BooleanReturn", + description = "Replace boolean return with true / false", + group = MutatorGroup.RETURN, + status = MutatorStatus.STABLE + ) + override fun matches(ret: IrReturn): Boolean { val value = ret.value diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/CollectionMethodOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/CollectionMethodOperator.kt new file mode 100644 index 0000000..96f70aa --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/CollectionMethodOperator.kt @@ -0,0 +1,157 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.declarations.IrFile +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.* +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.util.classId +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.Name + +/** + * Mutation operator for Kotlin stdlib collection-method calls. + * + * Maps Stryker's Scala collection-method mutators to Kotlin stdlib equivalents + * (see `docs/mutation-catalog.md` §3.10). All are backend-agnostic since the + * Kotlin stdlib is shared across JVM/JS/WASM/Native. + * + * Swaps (name → replacement): + * - `filter` ↔ `filterNot` + * - `any` ↔ `all` + * - `take` ↔ `drop` + * - `takeLast` ↔ `dropLast` + * - `isEmpty` ↔ `isNotEmpty` + * - `min` ↔ `max` + * - `minBy` ↔ `maxBy` + * - `minOf` ↔ `maxOf` + * + * (`indexOf`↔`lastIndexOf` is deliberately omitted — its replacement symbol is not + * present in the WASM function map, so it breaks Kotlin/WASM compilation.) + * + * Matched by receiver type (a collection/array/String) + method name, since + * these are regular (origin == null) member calls with no dedicated + * IrStatementOrigin. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class CollectionMethodOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "COLLECTION_METHOD", + name = "CollectionMethod", + description = "Swap filter↔filterNot, any↔all, take↔drop, isEmpty↔isNotEmpty, min↔max, minBy↔maxBy", + group = MutatorGroup.COLLECTION, + status = MutatorStatus.STABLE + ) + + private companion object { + private val SWAPS = mapOf( + "filter" to "filterNot", + "filterNot" to "filter", + "any" to "all", + "all" to "any", + "take" to "drop", + "drop" to "take", + "takeLast" to "dropLast", + "dropLast" to "takeLast", + "isEmpty" to "isNotEmpty", + "isNotEmpty" to "isEmpty", + "min" to "max", + "max" to "min", + "minBy" to "maxBy", + "maxBy" to "minBy", + "minOf" to "maxOf", + "maxOf" to "minOf" + ) + + private val COLLECTION_TYPES = setOf( + "kotlin.collections.Iterable", + "kotlin.collections.Collection", + "kotlin.collections.List", + "kotlin.collections.MutableCollection", + "kotlin.collections.MutableList", + "kotlin.collections.Set", + "kotlin.collections.MutableSet", + "kotlin.collections.Map", + "kotlin.collections.MutableMap", + "kotlin.Array", + "kotlin.String" + ) + } + + override fun matches(call: IrCall): Boolean { + if (call.origin != null) return false + if (!receiverTypeIsCollection(call)) return false + return call.symbol.owner.name.asString() in SWAPS + } + + private fun receiverTypeIsCollection(call: IrCall): Boolean { + call.dispatchReceiver?.let { return isCollectionType(it.type) } + // Extension functions: the receiver is the first parameter (kind ExtensionReceiver). + val params = call.symbol.owner.parameters + val extParam = params.firstOrNull { it.kind == org.jetbrains.kotlin.ir.declarations.IrParameterKind.ExtensionReceiver } + return isCollectionType(extParam?.type ?: return false) + } + + private fun isCollectionType(type: org.jetbrains.kotlin.ir.types.IrType): Boolean { + val fqName = type.classFqName?.asString() ?: return false + return fqName in COLLECTION_TYPES + } + + override fun originalDescription(call: IrCall): String = call.symbol.owner.name.asString() + + override fun variants(call: IrCall, context: MutationContext): List { + val name = call.symbol.owner.name.asString() + val replacementName = SWAPS[name] ?: return emptyList() + + val replacementFn = findFunction(call, replacementName, context) ?: return emptyList() + // For extension functions the receiver is argument 0. + val argStart = if (call.dispatchReceiver == null) 1 else 0 + val args = call.arguments.drop(argStart).map { it?.deepCopyWithSymbols() } + val receiver = call.dispatchReceiver ?: call.arguments.getOrNull(0) + + return listOf( + MutationOperator.Variant(replacementName) { + context.builder.irCall(replacementFn).also { newCall -> + if (call.dispatchReceiver != null) { + newCall.dispatchReceiver = receiver!!.deepCopyWithSymbols() + args.forEachIndexed { i, a -> newCall.arguments[i] = a } + } else { + newCall.arguments[0] = receiver!!.deepCopyWithSymbols() + args.forEachIndexed { i, a -> newCall.arguments[i + 1] = a } + } + } + } + ) + } + + private fun findFunction( + original: IrCall, + name: String, + context: MutationContext + ): IrSimpleFunctionSymbol? { + val owner = original.symbol.owner + // Member functions (isEmpty, indexOf, ...) have the class as parent. + val parentClass = owner.parent as? org.jetbrains.kotlin.ir.declarations.IrClass + val parentClassId = parentClass?.classId + if (parentClassId != null) { + return context.pluginContext.referenceFunctions(CallableId(parentClassId, Name.identifier(name))) + .firstOrNull() + } + // Extension functions (filter, min, any, ...) live as top-level functions in a + // package fragment. Find a sibling with the replacement name on the same package. + val packageFragment = (owner.parent as? org.jetbrains.kotlin.ir.declarations.IrPackageFragment) + ?: ((owner.parent as? org.jetbrains.kotlin.ir.declarations.IrDeclaration) + ?.parent as? org.jetbrains.kotlin.ir.declarations.IrPackageFragment) + ?: return null + return packageFragment.declarations + .filterIsInstance() + .flatMap { it.declarations.asSequence() } + .filterIsInstance() + .firstOrNull { it.name.asString() == name } + ?.symbol + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstMutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstMutationOperator.kt new file mode 100644 index 0000000..5d086da --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstMutationOperator.kt @@ -0,0 +1,38 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConst + +/** + * Abstraction for mutation operators that target constant expressions + * (e.g. string literals). + * + * Constants are leaf nodes in the IR tree, so unlike [MutationOperator] there + * is no recursion concern — each constant is a single mutation point. + */ +interface ConstMutationOperator { + + /** + * Declarative metadata for this operator (stable id, group, status). + */ + val descriptor: MutatorDescriptor + + /** + * Returns true if this operator can generate mutations for the given constant. + */ + fun matches(const: IrConst): Boolean + + /** + * Generates mutation variants for the given constant. + * + * @param const The original IR constant + * @param context Context providing access to plugin context and IR builder + * @return List of variants (not including the original) + */ + fun variants(const: IrConst, context: MutationContext): List + + /** + * Returns a description of the original operator for display. + * Example: `"foo"` for a string literal. + */ + fun originalDescription(const: IrConst): String +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstantBoundaryOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstantBoundaryOperator.kt index 5f762af..e0b39a7 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstantBoundaryOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstantBoundaryOperator.kt @@ -24,6 +24,14 @@ import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols @OptIn(UnsafeDuringIrConstructionAPI::class) class ConstantBoundaryOperator : MutationOperator { + override val descriptor = MutatorDescriptor( + id = "CONSTANT_BOUNDARY", + name = "ConstantBoundary", + description = "Numeric constant ±1 boundary in comparisons", + group = MutatorGroup.CONSTANT, + status = MutatorStatus.STABLE + ) + companion object { private val COMPARISON_ORIGINS = setOf( IrStatementOrigin.GT, diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstructorCallMutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstructorCallMutationOperator.kt new file mode 100644 index 0000000..4b02ed2 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstructorCallMutationOperator.kt @@ -0,0 +1,26 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConstructorCall + +/** + * Abstraction for mutation operators that match on [IrConstructorCall] nodes. + * + * In Kotlin 2.4.0 `IrConstructorCall` is a sibling of [org.jetbrains.kotlin.ir.expressions.IrCall] + * (both extend [org.jetbrains.kotlin.ir.expressions.IrFunctionAccessExpression]), so + * constructor calls never reach [MutationOperator] and need their own visitor path + * in the transformer. + */ +interface ConstructorCallMutationOperator { + + /** Declarative metadata for this operator (stable id, group, status). */ + val descriptor: MutatorDescriptor + + /** Returns true if this operator can generate mutations for the given constructor call. */ + fun matches(call: IrConstructorCall): Boolean + + /** Generates mutation variants for the given constructor call. */ + fun variants(call: IrConstructorCall, context: MutationContext): List + + /** Returns a description of the original constructor for display. */ + fun originalDescription(call: IrConstructorCall): String +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstructorCallOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstructorCallOperator.kt new file mode 100644 index 0000000..6fdcef1 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ConstructorCallOperator.kt @@ -0,0 +1,58 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConstructorCall +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.types.makeNullable + +/** + * Mutation operator for constructor calls: replaces the constructed object with `null`. + * + * Mirrors pitest's `CONSTRUCTOR_CALLS` mutator. Detects tests that don't verify + * object creation — e.g. a test that only checks the constructor doesn't throw + * will pass even when the object is never actually built. + * + * The null constant uses the nullable form of the constructed type; the enclosing + * `when` keeps the original (non-nullable) type, so an active mutant surfaces as a + * null where a real object was expected. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ConstructorCallOperator : ConstructorCallMutationOperator { + + companion object { + // Types whose members delegate to a native/JS-backed implementation (e.g. + // Regex's platform regex engine) where a null-deref segfaults uncatchably + // on Kotlin/Native and Kotlin/Wasm instead of throwing a catchable NPE like + // it does on JVM/JS. There's no reliable IR-level signal (e.g. `isExternal` + // on a member function) that flags this — the crash can originate several + // calls deep inside stdlib internals — so this has to be a manually + // maintained list of FQNs found to be unsafe in practice. + private val UNSAFE_NULL_DEREF_TYPES = setOf( + "kotlin.text.Regex" + ) + } + + override val descriptor = MutatorDescriptor( + id = "CONSTRUCTOR_CALL", + name = "ConstructorCall", + description = "Replace constructor call with null", + group = MutatorGroup.CALL, + status = MutatorStatus.STABLE + ) + + override fun matches(call: IrConstructorCall): Boolean = + call.type.classFqName?.asString() !in UNSAFE_NULL_DEREF_TYPES + + override fun originalDescription(call: IrConstructorCall): String = + call.symbol.owner.name.asString() + + override fun variants(call: IrConstructorCall, context: MutationContext): List { + val nullableType = call.type.makeNullable() + return listOf( + MutationOperator.Variant("null") { + IrConstImpl.constNull(call.startOffset, call.endOffset, nullableType) + } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ElvisOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ElvisOperator.kt new file mode 100644 index 0000000..82bef1b --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ElvisOperator.kt @@ -0,0 +1,58 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.makeNotNull +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Replaces `a ?: b` with `a` or `b`. + * + * In K2 IR the elvis lowers to a 2-branch when: the subject in branch 0, the + * fallback in branch 1. The origin lives on the enclosing block (common IR) + * or on the when itself (JVM-folded), which is why we also check + * [EnclosingOriginProvider]. The subject is `T?`, so the `a` variant goes + * through `checkNotNull` to keep the when's non-null type happy. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ElvisOperator : WhenMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "ELVIS", + name = "Elvis", + description = "Replace a ?: b with a / b", + group = MutatorGroup.KOTLIN_SPECIFIC, + status = MutatorStatus.STABLE + ) + + override fun matches(whenExpr: IrWhen): Boolean { + if (whenExpr.branches.size != 2) return false + return whenExpr.origin?.debugName == "FOLDED_ELVIS" || + EnclosingOriginProvider.currentOrigin == "ELVIS" || + EnclosingOriginProvider.currentOrigin == "FOLDED_ELVIS" + } + + override fun originalDescription(whenExpr: IrWhen): String = "?:" + + override fun variants(whenExpr: IrWhen, context: MutationContext): List { + val subject = whenExpr.branches[0].result ?: return emptyList() + val fallback = whenExpr.branches[1].result ?: return emptyList() + val checkNotNull = context.pluginContext.irBuiltIns.checkNotNullSymbol + + return listOf( + MutationOperator.Variant("b") { + fallback.deepCopyWithSymbols() + }, + MutationOperator.Variant("a") { + val notNullType = subject.type.makeNotNull() + context.builder.irCall(checkNotNull).also { + it.arguments[0] = subject.deepCopyWithSymbols() + it.typeArguments[0] = notNullType + it.type = notNullType + } + } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EmptyCollectionReturnOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EmptyCollectionReturnOperator.kt new file mode 100644 index 0000000..2cd9fee --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EmptyCollectionReturnOperator.kt @@ -0,0 +1,105 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.expressions.IrReturn +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.* +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.Name + +/** + * Replaces a collection return with the empty one: `return listOf(1,2,3)` → + * `return emptyList()`, and similarly for `emptySet()`/`emptyMap()`. + * + * Pitest's `EMPTY_RETURNS`. Fires on explicit source returns whose declared + * type is a `List`/`Set`/`Map`/`Collection`; keeps the return type's type + * arguments (so `List` → `emptyList()`). + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class EmptyCollectionReturnOperator : ReturnMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "RETURN_EMPTY_COLLECTION", + name = "EmptyCollectionReturn", + description = "Replace collection return with empty collection", + group = MutatorGroup.RETURN, + status = MutatorStatus.STABLE + ) + + private enum class CollectionKind { LIST, SET, MAP, COLLECTION } + + private val COLLECTION_KINDS = mapOf( + "kotlin.collections.List" to CollectionKind.LIST, + "kotlin.collections.Collection" to CollectionKind.LIST, + "kotlin.collections.MutableList" to CollectionKind.LIST, + "kotlin.collections.MutableCollection" to CollectionKind.LIST, + "kotlin.collections.Set" to CollectionKind.SET, + "kotlin.collections.MutableSet" to CollectionKind.SET, + "kotlin.collections.Map" to CollectionKind.MAP, + "kotlin.collections.MutableMap" to CollectionKind.MAP + ) + + private val EMPTY_FN_NAMES = mapOf( + CollectionKind.LIST to "emptyList", + CollectionKind.SET to "emptySet", + CollectionKind.MAP to "emptyMap" + ) + + override fun matches(ret: IrReturn): Boolean { + // Skip synthetic returns (expression-bodied functions). + if (ret.startOffset == UNDEFINED_OFFSET || ret.startOffset < 0) return false + if (ret.startOffset == ret.endOffset) return false + + val returnTarget = ret.returnTargetSymbol.owner + val functionReturnType = when (returnTarget) { + is IrFunction -> returnTarget.returnType + else -> ret.value.type + } + // Only a collection/map return type is a valid target. + return collectionKind(functionReturnType) != null + } + + override fun originalDescription(ret: IrReturn): String = "return ..." + + override fun variants(ret: IrReturn, context: MutationContext): List { + val returnTarget = ret.returnTargetSymbol.owner + val functionReturnType = when (returnTarget) { + is IrFunction -> returnTarget.returnType + else -> ret.value.type + } + val kind = collectionKind(functionReturnType) ?: return emptyList() + val emptyFnName = EMPTY_FN_NAMES[kind] ?: return emptyList() + + // Resolve the stdlib `emptyList`/`emptySet`/`emptyMap` function. + val emptyFn = context.pluginContext.referenceFunctions( + CallableId(org.jetbrains.kotlin.name.FqName("kotlin.collections"), Name.identifier(emptyFnName)) + ).firstOrNull() ?: return emptyList() + + // Preserve the type arguments of the return type (e.g. List → emptyList()). + val typeArguments = (functionReturnType as? org.jetbrains.kotlin.ir.types.IrSimpleType) + ?.arguments + ?.mapNotNull { it as? org.jetbrains.kotlin.ir.types.IrTypeProjection } + ?.map { it.type } + .orEmpty() + + return listOf( + MutationOperator.Variant(emptyFnName) { + context.builder.irCall(emptyFn).also { call -> + typeArguments.forEachIndexed { index, arg -> + if (index < call.typeArguments.size) { + call.typeArguments[index] = arg + } + } + } + } + ) + } + + private fun collectionKind(type: IrType): CollectionKind? { + val fqName = type.classFqName?.asString() ?: return null + return COLLECTION_KINDS[fqName] + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EnclosingOriginProvider.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EnclosingOriginProvider.kt new file mode 100644 index 0000000..f4afa40 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/EnclosingOriginProvider.kt @@ -0,0 +1,14 @@ +package io.github.anschnapp.mutflow.compiler + +/** + * Remembers the origin of the block currently being transformed. + * + * The elvis/safe-call whens have a null origin in common IR; the distinguishing + * origin sits on the enclosing block instead. [MutflowIrTransformer.visitBlock] + * records it here before descending, so when-operators can tell an elvis/safe-call + * apart from a plain when. Single-threaded per compilation, so a mutable object + * is fine. + */ +internal object EnclosingOriginProvider { + var currentOrigin: String? = null +} 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 c70f5ec..48a6cee 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 @@ -34,6 +34,14 @@ import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols @OptIn(UnsafeDuringIrConstructionAPI::class) class EqualitySwapOperator : MutationOperator { + override val descriptor = MutatorDescriptor( + id = "EQUALITY_SWAP", + name = "EqualitySwap", + description = "Swap == ↔ != (skips null comparisons)", + group = MutatorGroup.RELATIONAL, + status = MutatorStatus.STABLE + ) + override fun matches(call: IrCall): Boolean { return when { // == : EQEQ intrinsic with EQEQ origin (but not a null comparison) diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ForceConditionalOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ForceConditionalOperator.kt new file mode 100644 index 0000000..4db6b15 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ForceConditionalOperator.kt @@ -0,0 +1,79 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.expressions.impl.IrBranchImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrElseBranchImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrWhenImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator that forces an `if` condition to `true` / `false`. + * + * In Kotlin IR, `if (c) a else b` is an [IrWhen] with origin + * [IrStatementOrigin.IF] and two branches: `{ c -> a; else -> b }`. The + * mutation replaces the condition with a constant so the branch is always + * taken: + * - `true` → always take the then-branch + * - `false` → always take the else-branch + * + * Mirrors pitest's `NEGATE_CONDITIONALS` (force-true/false variants) and + * Stryker's `ConditionalExpression` mutator. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ForceConditionalOperator : WhenMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "FORCE_CONDITIONAL", + name = "ForceConditional", + description = "Force if condition to true/false", + group = MutatorGroup.CONTROL_FLOW, + status = MutatorStatus.STABLE + ) + + override fun matches(whenExpr: IrWhen): Boolean = + whenExpr.origin == IrStatementOrigin.IF + + override fun originalDescription(whenExpr: IrWhen): String = "if" + + override fun variants(whenExpr: IrWhen, context: MutationContext): List { + // Expect exactly 2 branches: condition + else. + if (whenExpr.branches.size != 2) return emptyList() + + val firstBranch = whenExpr.branches[0] + val elseBranch = whenExpr.branches[1] + val booleanType = context.pluginContext.irBuiltIns.booleanType + + fun forcedWhen(conditionValue: Boolean): IrWhenImpl = IrWhenImpl( + startOffset = whenExpr.startOffset, + endOffset = whenExpr.endOffset, + type = whenExpr.type, + origin = null + ).apply { + branches += IrBranchImpl( + startOffset = firstBranch.startOffset, + endOffset = firstBranch.endOffset, + condition = IrConstImpl.boolean( + firstBranch.condition.startOffset, + firstBranch.condition.endOffset, + booleanType, + conditionValue + ), + result = firstBranch.result.deepCopyWithSymbols() + ) + branches += IrElseBranchImpl( + startOffset = elseBranch.startOffset, + endOffset = elseBranch.endOffset, + condition = elseBranch.condition.deepCopyWithSymbols(), + result = elseBranch.result.deepCopyWithSymbols() + ) + } + + return listOf( + MutationOperator.Variant("true") { forcedWhen(true) }, + MutationOperator.Variant("false") { forcedWhen(false) } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/FunctionBodyMutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/FunctionBodyMutationOperator.kt index 6ebd0d2..52a53f3 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/FunctionBodyMutationOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/FunctionBodyMutationOperator.kt @@ -14,6 +14,11 @@ import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction */ interface FunctionBodyMutationOperator { + /** + * Declarative metadata for this operator (stable id, group, status). + */ + val descriptor: MutatorDescriptor + /** * Returns true if this operator can generate a mutation for the given function. */ diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/IncrementOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/IncrementOperator.kt new file mode 100644 index 0000000..b83e8da --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/IncrementOperator.kt @@ -0,0 +1,167 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.classId +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.Name + +/** + * Mutation operator for increment/decrement swaps: `++` ↔ `--`. + * + * Handles BOTH IR representations of `++`/`--`: + * - **K2 IR (full Gradle pipeline)**: `a++` is a call to the `inc` / `dec` + * member function with the operand as dispatch receiver. The mutation swaps + * the call for its counterpart on the same declaring class (e.g. `Int.inc` ↔ + * `Int.dec`). + * - **Intrinsic-call form (isolated CLI compilation)**: `a++` is a call to the + * synthetic `int-postfix-incr-decr` / `int-prefix-incr-decr` intrinsic with + * the operand and a delta constant (`1`). The mutation negates the delta so + * `++` becomes `--` and vice versa. + * + * Mirrors pitest's `INCREMENTS` and Mull's `cxx_post_inc_to_post_dec` / + * `cxx_pre_inc_to_pre_dec`. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class IncrementOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "INCREMENT", + name = "Increment", + description = "Swap ++ ↔ --", + group = MutatorGroup.ARITHMETIC, + status = MutatorStatus.STABLE + ) + + companion object { + private val INCR_DECR_NAMES = setOf("inc", "dec") + // Synthetic intrinsic names carry angle brackets (e.g. ``). + private val INTRINSIC_NAMES = setOf("", "") + } + + override fun matches(call: IrCall): Boolean { + val name = call.symbol.owner.name.asString() + return name in INCR_DECR_NAMES || name in INTRINSIC_NAMES + } + + override fun originalDescription(call: IrCall): String { + val name = call.symbol.owner.name.asString() + return when { + name == "inc" -> "++" + name == "dec" -> "--" + name in INTRINSIC_NAMES -> { + // The intrinsic name is the same for ++ and --; the delta sign + // distinguishes them (++ has delta +1, -- has delta -1). + val delta = call.arguments.getOrNull(1) as? IrConst + if (delta != null && isNegative(delta)) "--" else "++" + } + else -> "?" + } + } + + override fun variants(call: IrCall, context: MutationContext): List { + val name = call.symbol.owner.name.asString() + return when { + name in INCR_DECR_NAMES -> memberFunctionVariants(call, name, context) + name in INTRINSIC_NAMES -> intrinsicVariants(call, context) + else -> emptyList() + } + } + + /** + * K2 form: swap `inc` ↔ `dec` on the same declaring class. + */ + private fun memberFunctionVariants( + call: IrCall, + name: String, + context: MutationContext + ): List { + val replacementName = if (name == "inc") "dec" else "inc" + val receiver = call.dispatchReceiver ?: return emptyList() + + val replacementFn = findFunction(call, replacementName, context) ?: return emptyList() + + return listOf( + MutationOperator.Variant(replacementName) { + context.builder.irCall(replacementFn).also { + it.dispatchReceiver = receiver.deepCopyWithSymbols() + } + } + ) + } + + /** + * Intrinsic-call form: negate the delta constant so `++` becomes `--`. + */ + private fun intrinsicVariants(call: IrCall, context: MutationContext): List { + val delta = call.arguments.getOrNull(1) as? IrConst ?: return emptyList() + val negated = negateConstant(delta) ?: return emptyList() + val description = if (isNegative(delta)) "++" else "--" + + return listOf( + MutationOperator.Variant(description) { + context.builder.irCall(call.symbol).also { newCall -> + call.arguments.forEachIndexed { index, arg -> + if (arg != null) { + newCall.arguments[index] = arg.deepCopyWithSymbols() + } + } + newCall.arguments[1] = negated.deepCopyWithSymbols() + } + } + ) + } + + private fun isNegative(constant: IrConst): Boolean = when (val value = constant.value) { + is Int -> value < 0 + is Long -> value < 0 + is Short -> value < 0 + is Byte -> value < 0 + is Float -> value < 0 + is Double -> value < 0 + else -> false + } + + private fun negateConstant(constant: IrConst): IrConst? { + val startOffset = constant.startOffset + val endOffset = constant.endOffset + val type = constant.type + + return when (val value = constant.value) { + is Int -> IrConstImpl.int(startOffset, endOffset, type, -value) + is Long -> IrConstImpl.long(startOffset, endOffset, type, -value) + is Short -> IrConstImpl.short(startOffset, endOffset, type, (-value).toShort()) + is Byte -> IrConstImpl.byte(startOffset, endOffset, type, (-value).toByte()) + is Float -> IrConstImpl.float(startOffset, endOffset, type, -value) + is Double -> IrConstImpl.double(startOffset, endOffset, type, -value) + else -> null + } + } + + /** + * Finds the replacement function with the same signature as the original. + * `inc`/`dec` take no value parameters, so — unlike ArithmeticOperator's + * `plus`/`minus`, which have per-type overloads to disambiguate — matching + * by declaring class plus parameter count (dispatch receiver only) is + * sufficient to pick the right overload for primitives. + */ + private fun findFunction( + original: IrCall, + replacementName: String, + context: MutationContext + ): IrSimpleFunctionSymbol? { + val declaringClassId = (original.symbol.owner.parent as? IrClass)?.classId + ?: return null + val callableId = CallableId(declaringClassId, Name.identifier(replacementName)) + val candidates = context.pluginContext.referenceFunctions(callableId) + val originalParamCount = original.symbol.owner.parameters.size + return candidates.firstOrNull { fn -> fn.owner.parameters.size == originalParamCount } + ?: candidates.firstOrNull() + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationCatalog.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationCatalog.kt new file mode 100644 index 0000000..4a5b1ad --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationCatalog.kt @@ -0,0 +1,135 @@ +package io.github.anschnapp.mutflow.compiler + +/** + * Central registry of all mutation operators. + * + * This is the declarative catalog: the single place where operators are + * registered, grouped, and looked up. The IR transformer consumes the default + * sets below; reporting and filtering consume [allDescriptors]. + * + * The catalog mirrors `docs/mutation-catalog.md` — each operator's [MutatorDescriptor] + * carries the stable id, group, and status used across backends. + */ +object MutationCatalog { + + /** Operators that match on [org.jetbrains.kotlin.ir.expressions.IrCall] nodes. */ + val callOperators: List = listOf( + RelationalComparisonOperator(), + ConstantBoundaryOperator(), + ArithmeticOperator(), + EqualitySwapOperator(), + ReferenceEqualityOperator(), + BooleanInversionOperator(), + BooleanLogicOperator(), + UnaryMinusOperator(), + BitwiseOperator(), + IncrementOperator(), + ReplaceNonVoidCallOperator(), + StringMethodOperator(), + CollectionMethodOperator(), + // Experimental call operators, enabled by default but marked EXPERIMENTAL + // for reporting/filtering. + RemoveIncrementOperator(), + ArgumentPropagationOperator() + ) + + /** Operators that match on [org.jetbrains.kotlin.ir.expressions.IrReturn] nodes. */ + val returnOperators: List = listOf( + BooleanReturnOperator(), + NullableReturnOperator(), + PrimitiveReturnOperator(), + ObjectReturnOperator(), + EmptyCollectionReturnOperator() + ) + + /** Operators that match on [org.jetbrains.kotlin.ir.declarations.IrSimpleFunction] bodies. */ + val functionBodyOperators: List = listOf( + VoidFunctionBodyOperator() + ) + + /** + * Operators that match on [org.jetbrains.kotlin.ir.expressions.IrWhen] expressions. + * + * [BooleanLogicOperator] is registered in both the call and when lists because + * `&&`/`||` appear as an intrinsic `IrCall` in some compilations and as an + * `IrWhen` in others; only one form is present per compilation, so exactly one + * mutation point is generated. + */ + val whenOperators: List = listOf( + BooleanLogicOperator(), + ForceConditionalOperator(), + ElvisOperator(), + SafeCallOperator(), + SwitchOperator() + ) + + /** Operators that match on [org.jetbrains.kotlin.ir.expressions.IrConst] nodes. */ + val constOperators: List = listOf( + StringLiteralOperator(), + BooleanConstOperator() + ) + + /** + * Operators that match on [org.jetbrains.kotlin.ir.expressions.IrConstructorCall] nodes. + * + * Constructor calls are a distinct IR node type from [org.jetbrains.kotlin.ir.expressions.IrCall] + * in Kotlin 2.4.0, so they get their own list and transformer visitor path. + */ + val constructorCallOperators: List = listOf( + ConstructorCallOperator(), + RegexPatternOperator() + ) + + /** Operators that match on assignment nodes ([org.jetbrains.kotlin.ir.expressions.IrSetValue] + * and [org.jetbrains.kotlin.ir.expressions.IrSetField]). */ + val assignmentOperators: List = listOf( + AssignConstOperator() + ) + + /** + * Every registered operator's descriptor, for reporting and filtering. + * + * Deduplicated by identity because [BooleanLogicOperator] is registered in both + * the call and when lists (it handles both IR forms of `&&`/`||`). + */ + val allDescriptors: List = + (callOperators.map { it.descriptor } + + returnOperators.map { it.descriptor } + + functionBodyOperators.map { it.descriptor } + + whenOperators.map { it.descriptor } + + constOperators.map { it.descriptor } + + constructorCallOperators.map { it.descriptor } + + assignmentOperators.map { it.descriptor }).distinct() + + /** Descriptors of operators that are safe to enable by default. */ + val stableDescriptors: List = + allDescriptors.filter { it.status == MutatorStatus.STABLE } + + /** + * Looks up an operator descriptor by its stable id. + * + * @return the descriptor, or `null` if no operator with that id is registered. + */ + fun byId(id: String): MutatorDescriptor? = allDescriptors.firstOrNull { it.id == id } + + /** + * Returns all descriptors in the given [group]. + */ + fun byGroup(group: MutatorGroup): List = + allDescriptors.filter { it.group == group } + + /** + * Asserts catalog invariants. Throws [IllegalStateException] if any operator + * has a duplicate id or a blank field. Called once at plugin registration. + */ + fun validate() { + val ids = allDescriptors.map { it.id } + val duplicates = ids.groupingBy { it }.eachCount().filterValues { it > 1 }.keys + require(duplicates.isEmpty()) { "Duplicate mutator ids in catalog: $duplicates" } + allDescriptors.forEach { d -> + require(d.id.isNotBlank()) { "Mutator id must not be blank" } + require(d.name.isNotBlank()) { "Mutator name must not be blank for ${d.id}" } + require(d.description.isNotBlank()) { "Mutator description must not be blank for ${d.id}" } + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationOperator.kt index 57cd731..5bb8f02 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutationOperator.kt @@ -14,6 +14,11 @@ import org.jetbrains.kotlin.ir.expressions.IrExpression */ interface MutationOperator { + /** + * Declarative metadata for this operator (stable id, group, status). + */ + val descriptor: MutatorDescriptor + /** * Returns true if this operator can generate mutations for the given call. */ diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorDescriptor.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorDescriptor.kt new file mode 100644 index 0000000..bfe6e92 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorDescriptor.kt @@ -0,0 +1,24 @@ +package io.github.anschnapp.mutflow.compiler + +/** + * Declarative metadata for a mutation operator. + * + * This is the single source of truth for an operator's identity, independent of + * its IR-matching implementation. The [id] is stable across refactors and is the + * value used in reports and for enabling/disabling operators by name. + * + * @property id Stable, unique identifier (e.g. `"RELATIONAL_COMPARISON"`). + * Must not change across refactors — it is persisted in mutation reports. + * @property name Short display name (e.g. `"RelationalComparison"`). + * @property description Human-readable description of the transformation. + * @property group Category from the KMR catalog. + * @property status Stability tier; [MutatorStatus.EXPERIMENTAL] operators are + * excluded from the default set. + */ +data class MutatorDescriptor( + val id: String, + val name: String, + val description: String, + val group: MutatorGroup, + val status: MutatorStatus +) diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorGroup.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorGroup.kt new file mode 100644 index 0000000..6a3886a --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorGroup.kt @@ -0,0 +1,19 @@ +package io.github.anschnapp.mutflow.compiler + +/** + * Category of a mutation operator, mirroring the KMR operator catalog + * (`docs/mutation-catalog.md`). Used for grouping, filtering, and reporting. + */ +enum class MutatorGroup(val displayName: String) { + RELATIONAL("Relational"), + ARITHMETIC("Arithmetic"), + BOOLEAN("Boolean"), + CONSTANT("Constant"), + RETURN("Return"), + CALL("Call"), + CONTROL_FLOW("Control Flow"), + KOTLIN_SPECIFIC("Kotlin-specific"), + STRING("String"), + COLLECTION("Collection"), + REGEX("Regex") +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorStatus.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorStatus.kt new file mode 100644 index 0000000..ec5d5b3 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutatorStatus.kt @@ -0,0 +1,13 @@ +package io.github.anschnapp.mutflow.compiler + +/** + * Stability of a mutation operator. + * + * - [STABLE]: well-understood, low-noise operators that are safe to enable by default. + * - [EXPERIMENTAL]: operators that are high-noise, imprecise, or not yet validated + * across all backends. Must be opted into explicitly. + */ +enum class MutatorStatus { + STABLE, + EXPERIMENTAL +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowCompilerPluginRegistrar.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowCompilerPluginRegistrar.kt index f423bd8..606cd23 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowCompilerPluginRegistrar.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowCompilerPluginRegistrar.kt @@ -36,6 +36,8 @@ class MutflowCompilerPluginRegistrar : CompilerPluginRegistrar() { override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { debug("registerExtensions() called!") debug(" configuration: $configuration") + // Fail fast on catalog invariants (duplicate/blank ids) before any compilation. + MutationCatalog.validate() val targetPatterns = configuration.get(MUTFLOW_TARGET_PATTERNS_KEY) ?: emptyList() debug(" target patterns: $targetPatterns") IrGenerationExtension.registerExtension(MutflowIrGenerationExtension(targetPatterns)) diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowIrTransformer.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowIrTransformer.kt index 512fd83..eb63f99 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowIrTransformer.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/MutflowIrTransformer.kt @@ -10,6 +10,7 @@ import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction import org.jetbrains.kotlin.ir.expressions.IrBlockBody import org.jetbrains.kotlin.ir.expressions.* import org.jetbrains.kotlin.ir.types.isBoolean +import org.jetbrains.kotlin.ir.types.isUnit import org.jetbrains.kotlin.ir.expressions.impl.IrBlockImpl import org.jetbrains.kotlin.ir.expressions.impl.IrBranchImpl import org.jetbrains.kotlin.ir.expressions.impl.IrElseBranchImpl @@ -38,46 +39,28 @@ class MutflowIrTransformer( private val returnOperators: List = defaultReturnOperators(), private val functionBodyOperators: List = defaultFunctionBodyOperators(), private val whenOperators: List = defaultWhenOperators(), + private val constOperators: List = defaultConstOperators(), + private val constructorCallOperators: List = defaultConstructorCallOperators(), + private val assignmentOperators: List = defaultAssignmentOperators(), private val targetPatterns: List = emptyList() ) : IrElementTransformerVoid() { companion object { - private const val ENABLE_DEBUG_LOGGING = false - - private fun debug(msg: String) { - if (ENABLE_DEBUG_LOGGING) { - // Use System.err which reliably shows in build output - System.err.println("[MUTFLOW] $msg") - // Also try to write to a log file in user home (more reliable than /tmp) - try { - val logFile = java.io.File(System.getProperty("user.home"), "mutflow-debug.log") - logFile.appendText("[MUTFLOW] $msg\n") - } catch (_: Exception) { - // Ignore file write errors - } - } - } + fun defaultCallOperators(): List = MutationCatalog.callOperators - fun defaultCallOperators(): List = listOf( - RelationalComparisonOperator(), - ConstantBoundaryOperator(), - ArithmeticOperator(), - EqualitySwapOperator(), - BooleanInversionOperator() - ) + fun defaultReturnOperators(): List = MutationCatalog.returnOperators - fun defaultReturnOperators(): List = listOf( - BooleanReturnOperator(), - NullableReturnOperator() - ) + fun defaultFunctionBodyOperators(): List = MutationCatalog.functionBodyOperators - fun defaultFunctionBodyOperators(): List = listOf( - VoidFunctionBodyOperator() - ) + fun defaultWhenOperators(): List = MutationCatalog.whenOperators - fun defaultWhenOperators(): List = listOf( - BooleanLogicOperator() - ) + fun defaultConstOperators(): List = MutationCatalog.constOperators + + fun defaultConstructorCallOperators(): List = + MutationCatalog.constructorCallOperators + + fun defaultAssignmentOperators(): List = + MutationCatalog.assignmentOperators /** * Compiles glob-style target patterns into regexes for FQN matching. @@ -134,11 +117,20 @@ class MutflowIrTransformer( private var suppressedLines: Set = emptySet() private val suppressedLinesCache = mutableMapOf>() + /** + * Boolean constants that form the structural shape of `&&`/`||` whens. + * + * The JVM backend asserts the exact shape of these whens (e.g. `&&` must end + * with an `else -> true -> false` pair), so mutating those constants would + * break lowering. The `&&` ↔ `||` swap is already handled by + * [BooleanLogicOperator], so skipping them loses no coverage. + */ + private var structuralBooleanConsts: Set = emptySet() + // Compiled target patterns from Gradle config (glob-style → regex) private val compiledTargetPatterns: List = compileTargetPatterns(targetPatterns) override fun visitFile(declaration: IrFile): IrFile { - debug("visitFile: ${declaration.fileEntry.name}") val previousFile = currentFile currentFile = declaration val result = super.visitFile(declaration) @@ -147,9 +139,6 @@ class MutflowIrTransformer( } override fun visitClass(declaration: IrClass): IrStatement { - debug("visitClass: ${declaration.fqNameWhenAvailable}") - debug(" annotations count: ${declaration.annotations.size}") - val wasMutationTarget = isInMutationTarget val wasSuppressed = isInSuppressedScope val previousSuppressedLines = suppressedLines @@ -159,8 +148,6 @@ class MutflowIrTransformer( || matchesTargetPattern(declaration) currentClass = declaration - debug(" isInMutationTarget: $isInMutationTarget") - // Check for @SuppressMutations on the class if (isInMutationTarget && declaration.hasAnnotation(suppressMutationsFqName)) { isInSuppressedScope = true @@ -172,7 +159,6 @@ class MutflowIrTransformer( // Parse source file for comment-based line suppression val filePath = currentFile?.fileEntry?.name suppressedLines = if (filePath != null) parseSuppressedLines(filePath) else emptySet() - debug(" -> WILL TRANSFORM this class!") } val result = super.visitClass(declaration) @@ -230,7 +216,67 @@ class MutflowIrTransformer( } val fn = currentFunction ?: return transformed - return transformCallWithOperators(transformed, fn, callOperators) + val result = transformCallWithOperators(transformed, fn, callOperators) + return result + } + + override fun visitConstructorCall(expression: IrConstructorCall): IrExpression { + // First, transform children (bottom-up for nested expressions) + val transformed = super.visitConstructorCall(expression) as IrConstructorCall + + // Only transform if we're in a @MutationTarget class and not suppressed + if (!isInMutationTarget || isInSuppressedScope) { + return transformed + } + if (isLineSuppressedByComment(transformed.startOffset)) { + return transformed + } + + val fn = currentFunction ?: return transformed + return transformConstructorCallWithOperators(transformed, fn, constructorCallOperators) + } + + override fun visitSetValue(expression: IrSetValue): IrExpression { + // First, transform children (bottom-up for nested expressions) + val transformed = super.visitSetValue(expression) as IrSetValue + + // Only transform if we're in a @MutationTarget class and not suppressed + if (!isInMutationTarget || isInSuppressedScope) { + return transformed + } + if (isLineSuppressedByComment(transformed.startOffset)) { + return transformed + } + + val fn = currentFunction ?: return transformed + val mutatedValue = transformAssignmentValue( + transformed.value, transformed.symbol.owner.type, transformed, fn + ) + if (mutatedValue === transformed.value) return transformed + // Rebuild the assignment with the mutation-guarded value. + transformed.value = mutatedValue + return transformed + } + + override fun visitSetField(expression: IrSetField): IrExpression { + // First, transform children (bottom-up for nested expressions) + val transformed = super.visitSetField(expression) as IrSetField + + if (!isInMutationTarget || isInSuppressedScope) { + return transformed + } + if (isLineSuppressedByComment(transformed.startOffset)) { + return transformed + } + + val fn = currentFunction ?: return transformed + val mutatedValue = transformAssignmentValue( + transformed.value, transformed.symbol.owner.type, transformed, fn + ) + if (mutatedValue === transformed.value) return transformed + // Rebuild the assignment with the mutation-guarded value. + transformed.value = mutatedValue + return transformed } override fun visitGetValue(expression: IrGetValue): IrExpression { @@ -272,8 +318,6 @@ class MutflowIrTransformer( val lineNumber = currentFile?.fileEntry?.getLineNumber(original.startOffset)?.plus(1) ?: 0 val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, varName) - debug("MUTATION: $varName at $sourceLocation (occurrence #$occurrenceOnLine) -> variants: !$varName") - fun createCheckCall() = builder.irCall(checkFn).also { call -> call.arguments[0] = builder.irGetObject(registryClass) call.arguments[1] = builder.irString(pointId) @@ -325,9 +369,27 @@ class MutflowIrTransformer( return transformReturnWithOperators(transformed, fn, returnOperators) } + override fun visitBlock(expression: IrBlock): IrExpression { + // Record the enclosing block's origin so when-operators (elvis, safe-call) + // can identify constructs whose distinguishing origin sits on this block + // rather than on the inner IrWhen (common-IR KMP form). + val previousOrigin = EnclosingOriginProvider.currentOrigin + EnclosingOriginProvider.currentOrigin = expression.origin?.debugName + try { + return super.visitBlock(expression) + } finally { + EnclosingOriginProvider.currentOrigin = previousOrigin + } + } + override fun visitWhen(expression: IrWhen): IrExpression { + // Record the structural boolean constants of &&/|| whens so const + // operators don't mutate them (the JVM backend asserts their shape). + val previousStructural = structuralBooleanConsts + structuralBooleanConsts = collectStructuralBooleanConsts(expression) // First, transform children (bottom-up for nested expressions) val transformed = super.visitWhen(expression) as IrWhen + structuralBooleanConsts = previousStructural // Only transform if we're in a @MutationTarget class and not suppressed if (!isInMutationTarget || isInSuppressedScope) { @@ -338,7 +400,65 @@ class MutflowIrTransformer( } val fn = currentFunction ?: return transformed - return transformWhenWithOperators(transformed, fn, whenOperators) + val result = transformWhenWithOperators(transformed, fn, whenOperators) + return result + } + + /** + * Returns the boolean constants that the JVM backend asserts on for `&&`/`||` + * whens: ANDAND requires `branches[1].condition == true` and + * `branches[1].result == false`; OROR requires `branches[0].result == true` + * and `branches[1].condition == true`. + */ + private fun collectStructuralBooleanConsts(whenExpr: IrWhen): Set { + val structural = mutableSetOf() + when (whenExpr.origin) { + IrStatementOrigin.ANDAND -> { + if (whenExpr.branches.size == 2) { + (whenExpr.branches[1].condition as? IrConst)?.let { structural += it } + (whenExpr.branches[1].result as? IrConst)?.let { structural += it } + } + } + IrStatementOrigin.OROR -> { + if (whenExpr.branches.size == 2) { + (whenExpr.branches[0].result as? IrConst)?.let { structural += it } + (whenExpr.branches[1].condition as? IrConst)?.let { structural += it } + } + } + else -> {} + } + // Protect the else-branch `true` const of any when whose last branch is a + // plain branch (not IrElseBranch) with a `true` condition. K2 represents + // `if/else` this way, and the JS backend's isElseBranch() relies on that + // const to recognize the implicit else. Mutating it (e.g. BooleanConstOperator + // wrapping it in a schemata when) breaks the JS backend's + // "Non unit when-expression must have else branch" assertion. + val last = whenExpr.branches.lastOrNull() + if (last != null) { + val cond = last.condition + if (cond is IrConst && cond.value == true) { + structural += cond + } + } + return structural + } + + override fun visitConst(expression: IrConst): IrExpression { + // Constants are leaf nodes, so no child transformation is needed. + if (!isInMutationTarget || isInSuppressedScope) { + return expression + } + if (isLineSuppressedByComment(expression.startOffset)) { + return expression + } + // Skip structural boolean constants of &&/|| whens (JVM backend asserts their shape). + if (expression in structuralBooleanConsts) { + return expression + } + + val fn = currentFunction ?: return expression + val result = transformConstWithOperators(expression, fn, constOperators) + return result } override fun visitWhileLoop(loop: IrWhileLoop): IrExpression { @@ -436,11 +556,9 @@ class MutflowIrTransformer( remainingOperators: List ): IrExpression { val checkFn = checkFunction ?: run { - debug("ERROR: checkFunction is NULL! MutationRegistry.check not found on classpath") return original } val registryClass = mutationRegistryClass ?: run { - debug("ERROR: mutationRegistryClass is NULL! MutationRegistry not found on classpath") return original } @@ -460,8 +578,6 @@ class MutflowIrTransformer( val lineNumber = currentFile?.fileEntry?.getLineNumber(original.startOffset)?.plus(1) ?: 0 val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalOperator) - debug("MUTATION: $originalOperator at $sourceLocation (occurrence #$occurrenceOnLine) -> variants: $variantOperators") - // Helper to create a fresh check() call for each branch condition fun createCheckCall() = builder.irCall(checkFn).also { call -> call.arguments[0] = builder.irGetObject(registryClass) @@ -497,6 +613,173 @@ class MutflowIrTransformer( } } + /** + * Recursively applies matching constructor-call operators to an expression. + * + * Mirrors [transformCallWithOperators] for [IrConstructorCall], which is a + * distinct IR node type from [IrCall] in Kotlin 2.4.0. + */ + private fun transformConstructorCallWithOperators( + original: IrConstructorCall, + containingFunction: IrSimpleFunction, + remainingOperators: List + ): IrExpression { + if (remainingOperators.isEmpty()) { + return original + } + + val operator = remainingOperators.first() + val rest = remainingOperators.drop(1) + + if (!operator.matches(original)) { + return transformConstructorCallWithOperators(original, containingFunction, rest) + } + + return transformConstructorCallWithOperator(original, containingFunction, operator, rest) + } + + /** + * Transforms a constructor call using the given mutation operator. + * + * Generates the same `when` + inline `check()` shape as [transformCallWithOperator]. + */ + private fun transformConstructorCallWithOperator( + original: IrConstructorCall, + containingFunction: IrSimpleFunction, + operator: ConstructorCallMutationOperator, + remainingOperators: List + ): IrExpression { + val checkFn = checkFunction ?: run { + return original + } + val registryClass = mutationRegistryClass ?: run { + return original + } + + val builder = DeclarationIrBuilder(pluginContext, containingFunction.symbol) + val context = MutationContext(pluginContext, builder, containingFunction) + + val variants = operator.variants(original, context) + if (variants.isEmpty()) { + return transformConstructorCallWithOperators(original, containingFunction, remainingOperators) + } + + val pointId = generatePointId() + val variantCount = variants.size + val sourceLocation = getSourceLocation(original) + val originalOperator = operator.originalDescription(original) + val variantOperators = variants.joinToString(",") { it.description } + val lineNumber = currentFile?.fileEntry?.getLineNumber(original.startOffset)?.plus(1) ?: 0 + val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalOperator) + + // Helper to create a fresh check() call for each branch condition + fun createCheckCall() = builder.irCall(checkFn).also { call -> + call.arguments[0] = builder.irGetObject(registryClass) + call.arguments[1] = builder.irString(pointId) + call.arguments[2] = builder.irInt(variantCount) + call.arguments[3] = builder.irString(sourceLocation) + call.arguments[4] = builder.irString(originalOperator) + call.arguments[5] = builder.irString(variantOperators) + call.arguments[6] = builder.irInt(occurrenceOnLine) + } + + // Generate when expression with inline check() calls - no temporary variable. + return IrWhenImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + type = original.type, + origin = null + ).apply { + variants.forEachIndexed { index, variant -> + branches += IrBranchImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + condition = builder.irEquals(createCheckCall(), builder.irInt(index)), + result = variant.createExpression() + ) + } + branches += IrElseBranchImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + condition = builder.irTrue(), + result = transformConstructorCallWithOperators(original, containingFunction, remainingOperators) + ) + } + } + + /** + * Applies matching assignment operators to an assignment's value, wrapping it + * in a mutation-guarded `when`. Returns the mutation-guarded value expression + * (or the original value if no operator matches). The caller assigns the result + * back to the assignment node's `value`. + */ + private fun transformAssignmentValue( + assignedValue: IrExpression, + targetType: org.jetbrains.kotlin.ir.types.IrType, + original: IrExpression, + containingFunction: IrSimpleFunction + ): IrExpression { + val checkFn = checkFunction ?: return assignedValue + val registryClass = mutationRegistryClass ?: return assignedValue + + val builder = DeclarationIrBuilder(pluginContext, containingFunction.symbol) + val context = MutationContext(pluginContext, builder, containingFunction) + + // Collect variants from all matching assignment operators. + val allVariants = mutableListOf() + val allOriginalOperators = mutableListOf() + for (operator in assignmentOperators) { + if (operator.matches(targetType, assignedValue)) { + val variants = operator.variants(targetType, assignedValue, context) + if (variants.isNotEmpty()) { + allVariants += variants + allOriginalOperators += operator.originalDescription(assignedValue) + } + } + } + if (allVariants.isEmpty()) return assignedValue + + val pointId = generatePointId() + val variantCount = allVariants.size + val sourceLocation = getSourceLocation(original) + val originalOperator = allOriginalOperators.firstOrNull() ?: "=" + val variantOperators = allVariants.joinToString(",") { it.description } + val lineNumber = currentFile?.fileEntry?.getLineNumber(original.startOffset)?.plus(1) ?: 0 + val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalOperator) + + fun createCheckCall() = builder.irCall(checkFn).also { call -> + call.arguments[0] = builder.irGetObject(registryClass) + call.arguments[1] = builder.irString(pointId) + call.arguments[2] = builder.irInt(variantCount) + call.arguments[3] = builder.irString(sourceLocation) + call.arguments[4] = builder.irString(originalOperator) + call.arguments[5] = builder.irString(variantOperators) + call.arguments[6] = builder.irInt(occurrenceOnLine) + } + + return IrWhenImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + type = targetType, + origin = null + ).apply { + allVariants.forEachIndexed { index, variant -> + branches += IrBranchImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + condition = builder.irEquals(createCheckCall(), builder.irInt(index)), + result = variant.createExpression() + ) + } + branches += IrElseBranchImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + condition = builder.irTrue(), + result = assignedValue + ) + } + } + /** * Applies matching return operators to a return statement. * @@ -558,9 +841,6 @@ class MutflowIrTransformer( val lineNumber = currentFile?.fileEntry?.getLineNumber(original.value.startOffset)?.plus(1) ?: 0 val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalDescription) - val fnName = containingFunction.name.asString() - debug("MUTATION: RETURN in $fnName at $sourceLocation (occurrence #$occurrenceOnLine) -> variants: $variantDescriptions") - val originalValue = original.value // Use the function's return type for the when type @@ -674,8 +954,6 @@ class MutflowIrTransformer( val lineNumber = currentFile?.fileEntry?.getLineNumber(original.startOffset)?.plus(1) ?: 0 val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalOperator) - debug("MUTATION: $originalOperator at $sourceLocation (occurrence #$occurrenceOnLine) -> variants: $variantOperators") - fun createCheckCall() = builder.irCall(checkFn).also { call -> call.arguments[0] = builder.irGetObject(registryClass) call.arguments[1] = builder.irString(pointId) @@ -689,7 +967,7 @@ class MutflowIrTransformer( return IrWhenImpl( startOffset = original.startOffset, endOffset = original.endOffset, - type = pluginContext.irBuiltIns.booleanType, + type = original.type, origin = null ).apply { variants.forEachIndexed { index, variant -> @@ -709,6 +987,100 @@ class MutflowIrTransformer( } } + /** + * Recursively applies matching const operators to an IrConst expression. + * + * Each matching operator wraps the constant in a mutation check, with the + * else branch passing to the next operator. + */ + private fun transformConstWithOperators( + original: IrConst, + containingFunction: IrSimpleFunction, + remainingOperators: List + ): IrExpression { + if (remainingOperators.isEmpty()) { + return original + } + + val operator = remainingOperators.first() + val rest = remainingOperators.drop(1) + + if (!operator.matches(original)) { + return transformConstWithOperators(original, containingFunction, rest) + } + + return transformConstWithOperator(original, containingFunction, operator, rest) + } + + /** + * Transforms a constant expression using the given mutation operator. + * + * Generates a when expression with inline check() calls (no temporary variable): + * ``` + * when { + * MutationRegistry.check(...) == 0 -> + * else -> + * } + * ``` + */ + private fun transformConstWithOperator( + original: IrConst, + containingFunction: IrSimpleFunction, + operator: ConstMutationOperator, + remainingOperators: List + ): IrExpression { + val checkFn = checkFunction ?: return original + val registryClass = mutationRegistryClass ?: return original + + val builder = DeclarationIrBuilder(pluginContext, containingFunction.symbol) + val context = MutationContext(pluginContext, builder, containingFunction) + + val variants = operator.variants(original, context) + if (variants.isEmpty()) { + return transformConstWithOperators(original, containingFunction, remainingOperators) + } + + val pointId = generatePointId() + val variantCount = variants.size + val sourceLocation = getSourceLocation(original) + val originalOperator = operator.originalDescription(original) + val variantOperators = variants.joinToString(",") { it.description } + val lineNumber = currentFile?.fileEntry?.getLineNumber(original.startOffset)?.plus(1) ?: 0 + val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalOperator) + + fun createCheckCall() = builder.irCall(checkFn).also { call -> + call.arguments[0] = builder.irGetObject(registryClass) + call.arguments[1] = builder.irString(pointId) + call.arguments[2] = builder.irInt(variantCount) + call.arguments[3] = builder.irString(sourceLocation) + call.arguments[4] = builder.irString(originalOperator) + call.arguments[5] = builder.irString(variantOperators) + call.arguments[6] = builder.irInt(occurrenceOnLine) + } + + return IrWhenImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + type = original.type, + origin = null + ).apply { + variants.forEachIndexed { index, variant -> + branches += IrBranchImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + condition = builder.irEquals(createCheckCall(), builder.irInt(index)), + result = variant.createExpression() + ) + } + branches += IrElseBranchImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + condition = builder.irTrue(), + result = transformConstWithOperators(original, containingFunction, remainingOperators) + ) + } + } + /** * Applies matching function body operators to a function declaration. * @@ -740,8 +1112,6 @@ class MutflowIrTransformer( val lineNumber = currentFile?.fileEntry?.getLineNumber(declaration.startOffset)?.plus(1) ?: 0 val occurrenceOnLine = nextOccurrenceOnLine(lineNumber, originalDescription) - debug("MUTATION: BODY of $originalDescription at $sourceLocation (occurrence #$occurrenceOnLine) -> variants: $variantDescriptions") - fun createCheckCall() = builder.irCall(checkFn).also { call -> call.arguments[0] = builder.irGetObject(registryClass) call.arguments[1] = builder.irString(pointId) @@ -905,7 +1275,6 @@ class MutflowIrTransformer( } } - debug("Parsed suppressed lines for $filePath: $suppressed") suppressedLinesCache[filePath] = suppressed return suppressed } diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/NullableReturnOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/NullableReturnOperator.kt index b8c3cc5..aa1fe53 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/NullableReturnOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/NullableReturnOperator.kt @@ -35,6 +35,14 @@ import org.jetbrains.kotlin.ir.types.isNullable @OptIn(UnsafeDuringIrConstructionAPI::class) class NullableReturnOperator : ReturnMutationOperator { + override val descriptor = MutatorDescriptor( + id = "RETURN_NULLABLE", + name = "NullableReturn", + description = "Replace nullable return with null", + group = MutatorGroup.RETURN, + status = MutatorStatus.STABLE + ) + override fun matches(ret: IrReturn): Boolean { val value = ret.value diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ObjectReturnOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ObjectReturnOperator.kt new file mode 100644 index 0000000..0e9487b --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ObjectReturnOperator.kt @@ -0,0 +1,90 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrReturn +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isNullable +import org.jetbrains.kotlin.ir.types.isPrimitiveType +import org.jetbrains.kotlin.ir.types.makeNullable + +/** + * Mutation operator for object return statements. + * + * Replaces the return value of a non-nullable object type (String, List, custom + * classes, ...) with `null` to detect tests that don't verify the actual + * returned object. Mirrors pitest's `RETURNS` mutator (object → null). + * + * Example: + * ``` + * // Original + * fun name(): String = user.name + * + * // Variant: return null + * ``` + * + * Skips primitive/boolean/char returns (handled by [PrimitiveReturnOperator] / + * [BooleanReturnOperator]), nullable returns (handled by + * [NullableReturnOperator]), and returns that are already null. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ObjectReturnOperator : ReturnMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "RETURN_OBJECT", + name = "ObjectReturn", + description = "Replace object return with null", + group = MutatorGroup.RETURN, + status = MutatorStatus.STABLE + ) + + override fun matches(ret: IrReturn): Boolean { + val value = ret.value + + // Skip synthetic returns (expression-bodied functions get synthetic IrReturn nodes) + if (ret.startOffset == UNDEFINED_OFFSET || ret.startOffset < 0) return false + if (ret.startOffset == ret.endOffset) return false + + // Get the function's return type (not the expression type, which may differ) + val returnTarget = ret.returnTargetSymbol.owner + val functionReturnType = when (returnTarget) { + is IrFunction -> returnTarget.returnType + else -> value.type + } + + // Must be a non-nullable object type + if (functionReturnType.isNullable()) return false + if (functionReturnType.isPrimitiveType()) return false + + // Skip if already returning null (mutating null to null is pointless) + if (value is IrConst && value.value == null) return false + + return true + } + + override fun originalDescription(ret: IrReturn): String = "return ..." + + override fun variants(ret: IrReturn, context: MutationContext): List { + val value = ret.value + + // The null constant must have a nullable type (e.g. String? for a String return). + val returnTarget = ret.returnTargetSymbol.owner + val functionReturnType = when (returnTarget) { + is IrFunction -> returnTarget.returnType + else -> value.type + } + val nullableType = functionReturnType.makeNullable() + + return listOf( + MutationOperator.Variant("null") { + IrConstImpl.constNull( + value.startOffset, + value.endOffset, + nullableType + ) + } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/PrimitiveReturnOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/PrimitiveReturnOperator.kt new file mode 100644 index 0000000..9b1ebaa --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/PrimitiveReturnOperator.kt @@ -0,0 +1,72 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrReturn +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.types.* + +/** + * Mutation operator for primitive (numeric) return statements. + * + * Replaces the return value with `0` to detect tests that don't verify the + * actual numeric result. Mirrors pitest's `PRIMITIVE_RETURNS` mutator. + * + * Example: + * ``` + * // Original + * fun balance(): Int = account.balance + * + * // Variant: return 0 + * ``` + * + * Skips returns that are already a constant (mutating `return 0` to `return 0` + * is pointless) and boolean returns (handled by [BooleanReturnOperator]). + */ +class PrimitiveReturnOperator : ReturnMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "RETURN_PRIMITIVE", + name = "PrimitiveReturn", + description = "Replace numeric return with 0", + group = MutatorGroup.RETURN, + status = MutatorStatus.STABLE + ) + + override fun matches(ret: IrReturn): Boolean { + val value = ret.value + + // Skip synthetic returns (expression-bodied functions get synthetic IrReturn nodes) + if (ret.startOffset == UNDEFINED_OFFSET || ret.startOffset < 0) return false + if (ret.startOffset == ret.endOffset) return false + // Skip returns that are already a constant + if (value is IrConst) return false + + return value.type.isInt() || value.type.isLong() || value.type.isFloat() || + value.type.isDouble() || value.type.isShort() || value.type.isByte() + } + + override fun originalDescription(ret: IrReturn): String = "return ..." + + override fun variants(ret: IrReturn, context: MutationContext): List { + val value = ret.value + val type = value.type + return listOf( + MutationOperator.Variant("0") { + createZeroConstant(type, value.startOffset, value.endOffset) + } + ) + } + + private fun createZeroConstant(type: IrType, startOffset: Int, endOffset: Int): IrConstImpl { + return when { + type.isInt() -> IrConstImpl.int(startOffset, endOffset, type, 0) + type.isLong() -> IrConstImpl.long(startOffset, endOffset, type, 0L) + type.isFloat() -> IrConstImpl.float(startOffset, endOffset, type, 0.0f) + type.isDouble() -> IrConstImpl.double(startOffset, endOffset, type, 0.0) + type.isShort() -> IrConstImpl.short(startOffset, endOffset, type, 0) + type.isByte() -> IrConstImpl.byte(startOffset, endOffset, type, 0) + else -> error("Unsupported primitive return type: $type") + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReferenceEqualityOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReferenceEqualityOperator.kt new file mode 100644 index 0000000..9daf0ab --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReferenceEqualityOperator.kt @@ -0,0 +1,69 @@ +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.IrStatementOrigin +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Flips `===` ↔ `!==`. + * + * Mirrors [EqualitySwapOperator]'s IR shapes: `===` is an `EQEQEQ` intrinsic, + * and `!==` is that wrapped in `Boolean.not()`. We match the outer `not()` for + * `!==` (not the inner intrinsic) so each one yields exactly one point. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ReferenceEqualityOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "REFERENCE_EQUALITY_SWAP", + name = "ReferenceEqualitySwap", + description = "Swap === ↔ !==", + group = MutatorGroup.RELATIONAL, + status = MutatorStatus.STABLE + ) + + override fun matches(call: IrCall): Boolean { + return when { + // a === b + call.origin == IrStatementOrigin.EQEQEQ && + call.symbol.owner.name.asString() == "EQEQEQ" -> true + // a !== b : not(EQEQEQ(a, b)) + call.origin == IrStatementOrigin.EXCLEQEQ && + call.symbol.owner.name.asString() == "not" -> { + call.dispatchReceiver is IrCall + } + else -> false + } + } + + override fun originalDescription(call: IrCall): String { + return when (call.origin) { + IrStatementOrigin.EQEQEQ -> "===" + IrStatementOrigin.EXCLEQEQ -> "!==" + else -> "?" + } + } + + override fun variants(call: IrCall, context: MutationContext): List { + return when (call.origin) { + // === → !== : wrap in not() + IrStatementOrigin.EQEQEQ -> listOf( + MutationOperator.Variant("!==") { + val booleanNotSymbol = context.pluginContext.irBuiltIns.booleanNotSymbol + context.builder.irCall(booleanNotSymbol).also { + it.dispatchReceiver = call.deepCopyWithSymbols() + } + } + ) + // !== → === : unwrap the not() + IrStatementOrigin.EXCLEQEQ -> listOf( + MutationOperator.Variant("===") { + call.dispatchReceiver!!.deepCopyWithSymbols() + } + ) + else -> emptyList() + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RegexPatternOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RegexPatternOperator.kt new file mode 100644 index 0000000..c5f7653 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RegexPatternOperator.kt @@ -0,0 +1,145 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrConstructorCall +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator for `Regex` pattern strings. + * + * Mirrors Stryker's weapon-regex `Regex` mutator set (§3.11). The pattern is a + * string constant passed to the `Regex(...)` constructor, which is backend-agnostic + * (a plain string constant on JVM/JS/Native). For a pattern constant we generate + * a small set of Level-1 mutations, applied to the *pattern string argument*: + * - **anchor**: remove leading `^` / trailing `$` + * - **class negate**: `[abc]` → `[^abc]` + * - **shorthand**: `\d`↔`\D`, `\w`↔`\W`, `\s`↔`\S` + * - **quantifier**: remove `*`, `+`, `?`, `{n,m}` following an atom + * + * The mutated variant rebuilds the whole `Regex(...)` constructor call with the + * swapped pattern, keeping the enclosing `when`'s (non-null) type. The pattern + * argument must be a literal string constant for the mutation to apply. + * + * Experimental: string rewriting is best-effort and can yield equivalent or + * invalid patterns; must be opted into explicitly. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class RegexPatternOperator : ConstructorCallMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "REGEX_PATTERN", + name = "RegexPattern", + description = "Mutate Regex pattern string (anchors, classes, shorthand, quantifiers)", + group = MutatorGroup.REGEX, + status = MutatorStatus.EXPERIMENTAL + ) + + override fun matches(call: IrConstructorCall): Boolean { + // Detect the Regex class from the constructor call's result type, which is + // reliable across backends. The pattern must be a string literal (possibly + // already wrapped in a schemata `when` by StringLiteralOperator in the real + // compilation), so extract its value via [extractPattern]. + val typeFqName = call.type.classFqName?.asString() + if (typeFqName != "kotlin.text.Regex") return false + return extractPattern(call.arguments.getOrNull(0)) != null + } + + override fun originalDescription(call: IrConstructorCall): String { + val pattern = extractPattern(call.arguments.getOrNull(0)) + return "Regex(\"$pattern\")" + } + + override fun variants(call: IrConstructorCall, context: MutationContext): List { + val pattern = extractPattern(call.arguments.getOrNull(0)) ?: return emptyList() + + val mutations = mutatePattern(pattern) + if (mutations.isEmpty()) return emptyList() + + val patternArg = call.arguments.getOrNull(0) + val patternType = patternArg?.type ?: return emptyList() + val patternOffset = patternArg.startOffset + val patternEnd = patternArg.endOffset + + return mutations.map { mutated -> + MutationOperator.Variant("Regex(\"$mutated\")") { + // Rebuild the constructor call by deep-copying the original (preserving + // its bound symbol and type arguments) and swapping the pattern argument + // for the mutated literal. + val newCall = call.deepCopyWithSymbols() + newCall.arguments[0] = IrConstImpl.string(patternOffset, patternEnd, patternType, mutated) + newCall + } + } + } + + /** + * Extracts the pattern string from a Regex constructor's first argument. Handles + * both a direct string literal and a string literal wrapped in a schemata `when` + * (produced by StringLiteralOperator when the real compiler plugin runs first): + * the original const sits in the `when`'s trailing else-branch result. + */ + private fun extractPattern(arg: IrExpression?): String? { + val direct = (arg as? IrConst)?.value as? String + if (direct != null) return direct + val whenExpr = arg as? org.jetbrains.kotlin.ir.expressions.IrWhen ?: return null + // Trailing branch is the else; recurse into its result to find the original const. + val last = whenExpr.branches.lastOrNull() ?: return null + return extractPattern(last.result) + } + + /** + * Returns the set of mutated pattern strings. Best-effort string rewriting; + * identical patterns are dropped. + */ + internal fun mutatePattern(pattern: String): List { + val out = mutableSetOf() + + // Anchor: remove leading ^ / trailing $. + if (pattern.startsWith("^")) out += pattern.removePrefix("^") + if (pattern.endsWith("$")) out += pattern.removeSuffix("$") + + // Shorthand: \d↔\D, \w↔\W, \s↔\S (both directions, first occurrence). + for ((from, to) in listOf( + "\\d" to "\\D", "\\D" to "\\d", + "\\w" to "\\W", "\\W" to "\\w", + "\\s" to "\\S", "\\S" to "\\s" + )) { + val idx = pattern.indexOf(from) + if (idx >= 0) out += pattern.replaceRange(idx, idx + from.length, to) + } + + // Char class: [abc] → [^abc] (first occurrence). Insert `^` right after `[`. + val open = pattern.indexOf('[') + if (open >= 0) { + val close = pattern.indexOf(']', open) + if (close > open) { + val cls = pattern.substring(open + 1, close) + if (!cls.startsWith("^")) { + out += pattern.replaceRange(open + 1, open + 1, "^") + } + } + } + + // Quantifier: remove * / + / ? / {n,m} following an atom (first occurrence). + for (i in pattern.indices) { + val c = pattern[i] + if (c == '*' || c == '+' || c == '?') { + out += pattern.removeRange(i, i + 1) + break + } + if (c == '{') { + val closeBrace = pattern.indexOf('}', i) + if (closeBrace > i) { + out += pattern.removeRange(i, closeBrace + 1) + break + } + } + } + + return out.toList() + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RelationalComparisonOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RelationalComparisonOperator.kt index e592e51..4c35e40 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RelationalComparisonOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RelationalComparisonOperator.kt @@ -23,6 +23,14 @@ import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols @OptIn(UnsafeDuringIrConstructionAPI::class) class RelationalComparisonOperator : MutationOperator { + override val descriptor = MutatorDescriptor( + id = "RELATIONAL_COMPARISON", + name = "RelationalComparison", + description = "Boundary (add/remove equality) and direction flip for <, >, <=, >=", + group = MutatorGroup.RELATIONAL, + status = MutatorStatus.STABLE + ) + companion object { private val SUPPORTED_ORIGINS = setOf( IrStatementOrigin.GT, diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RemoveIncrementOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RemoveIncrementOperator.kt new file mode 100644 index 0000000..f1e88f3 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/RemoveIncrementOperator.kt @@ -0,0 +1,86 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator that removes an increment/decrement: `a++` → `a`. + * + * The `inc`/`dec` call is replaced by its operand, so the surrounding assignment + * `a = a.inc()` becomes the no-op `a = a`. Detects tests that don't verify the + * incremented value. Mirrors pitest's `REMOVE_INCREMENTS` (experimental). + * + * Handles both IR forms of `++`/`--`: + * - **K2 IR (full Gradle pipeline)**: `a++` is a call to the `inc` / `dec` + * member function; the operand is the dispatch receiver. + * - **Intrinsic-call form (isolated CLI compilation)**: `a++` is a call to the + * synthetic `` / `` intrinsic; + * the operand is `arguments[0]` and the delta constant is `arguments[1]`. + * + * Experimental: high-noise, must be opted into explicitly (not in the default + * operator set). + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class RemoveIncrementOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "REMOVE_INCREMENT", + name = "RemoveIncrement", + description = "Remove increment/decrement (a++ → a)", + group = MutatorGroup.ARITHMETIC, + status = MutatorStatus.EXPERIMENTAL + ) + + companion object { + private val INCR_DECR_NAMES = setOf("inc", "dec") + // Synthetic intrinsic names carry angle brackets (e.g. ``). + private val INTRINSIC_NAMES = setOf("", "") + } + + override fun matches(call: IrCall): Boolean { + val name = call.symbol.owner.name.asString() + return name in INCR_DECR_NAMES || name in INTRINSIC_NAMES + } + + override fun originalDescription(call: IrCall): String { + val name = call.symbol.owner.name.asString() + return when { + name == "inc" -> "++" + name == "dec" -> "--" + name in INTRINSIC_NAMES -> { + // The intrinsic name is the same for ++ and --; the delta sign + // distinguishes them (++ has delta +1, -- has delta -1). + val delta = call.arguments.getOrNull(1) as? IrConst + if (delta != null && isNegative(delta)) "--" else "++" + } + else -> "?" + } + } + + override fun variants(call: IrCall, context: MutationContext): List { + val name = call.symbol.owner.name.asString() + val operand = when { + name in INCR_DECR_NAMES -> call.dispatchReceiver + name in INTRINSIC_NAMES -> call.arguments.getOrNull(0) + else -> null + } ?: return emptyList() + + return listOf( + MutationOperator.Variant("noop") { + operand.deepCopyWithSymbols() + } + ) + } + + private fun isNegative(constant: IrConst): Boolean = when (val value = constant.value) { + is Int -> value < 0 + is Long -> value < 0 + is Short -> value < 0 + is Byte -> value < 0 + is Float -> value < 0 + is Double -> value < 0 + else -> false + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReplaceNonVoidCallOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReplaceNonVoidCallOperator.kt new file mode 100644 index 0000000..9b6c729 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReplaceNonVoidCallOperator.kt @@ -0,0 +1,80 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.* +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator that replaces a non-void method call with the default value + * of its return type. + * + * Scoped to regular method calls (`origin == null`) so operator calls + * (`a + b`, `a > b`, ...) are left to their dedicated operators. The variant + * replaces the whole call with a default constant: + * - numeric → `0` + * - `Char` → `'a'` + * - `String` → `""` + * - object → `null` + * + * Mirrors pitest's `NON_VOID_METHOD_CALLS` and Mull's + * `cxx_replace_scalar_call`. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class ReplaceNonVoidCallOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "NON_VOID_CALL", + name = "ReplaceNonVoidCall", + description = "Replace non-void call with default value", + group = MutatorGroup.CALL, + status = MutatorStatus.STABLE + ) + + override fun matches(call: IrCall): Boolean { + // Only regular method calls (operator calls have non-null origins). + if (call.origin != null) return false + + val returnType = call.type + // Skip void and boolean returns (boolean handled by other operators). + if (returnType.isUnit() || returnType.isBoolean()) return false + + return true + } + + override fun originalDescription(call: IrCall): String = + call.symbol.owner.name.asString() + + override fun variants(call: IrCall, context: MutationContext): List { + val defaultValue = defaultForType(call.type, call.startOffset, call.endOffset) + ?: return emptyList() + + return listOf( + MutationOperator.Variant(defaultValue.description()) { + defaultValue.deepCopyWithSymbols() + } + ) + } + + private fun IrExpression.description(): String = when (this) { + is IrConst -> value.toString() + else -> "null" + } + + private fun defaultForType(type: IrType, startOffset: Int, endOffset: Int): IrExpression? { + return when { + type.isInt() -> IrConstImpl.int(startOffset, endOffset, type, 0) + type.isLong() -> IrConstImpl.long(startOffset, endOffset, type, 0L) + type.isShort() -> IrConstImpl.short(startOffset, endOffset, type, 0) + type.isByte() -> IrConstImpl.byte(startOffset, endOffset, type, 0) + type.isFloat() -> IrConstImpl.float(startOffset, endOffset, type, 0.0f) + type.isDouble() -> IrConstImpl.double(startOffset, endOffset, type, 0.0) + type.isChar() -> IrConstImpl.char(startOffset, endOffset, type, 'a') + type.isString() -> IrConstImpl.string(startOffset, endOffset, type, "") + else -> IrConstImpl.constNull(startOffset, endOffset, type.makeNullable()) + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReturnMutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReturnMutationOperator.kt index ca39613..bb15d88 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReturnMutationOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/ReturnMutationOperator.kt @@ -10,6 +10,11 @@ import org.jetbrains.kotlin.ir.expressions.IrReturn */ interface ReturnMutationOperator { + /** + * Declarative metadata for this operator (stable id, group, status). + */ + val descriptor: MutatorDescriptor + /** * Returns true if this operator can generate mutations for the given return statement. */ diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/SafeCallOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/SafeCallOperator.kt new file mode 100644 index 0000000..e49ca59 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/SafeCallOperator.kt @@ -0,0 +1,43 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Replaces `a?.b` with `a!!.b` — i.e. drops the null guard. + * + * The safe call lowers to a 2-branch when: branch 0 is the member access, branch 1 + * the `null` result. We take branch 0's result. Origin detection mirrors + * [ElvisOperator] (on the enclosing block in common IR, on the when when folded). + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class SafeCallOperator : WhenMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "SAFE_CALL", + name = "SafeCall", + description = "Replace a?.b with a!!.b (drop null guard)", + group = MutatorGroup.KOTLIN_SPECIFIC, + status = MutatorStatus.STABLE + ) + + override fun matches(whenExpr: IrWhen): Boolean { + if (whenExpr.branches.size != 2) return false + return whenExpr.origin?.debugName == "FOLDED_SAFE_CALL" || + EnclosingOriginProvider.currentOrigin == "SAFE_CALL" || + EnclosingOriginProvider.currentOrigin == "FOLDED_SAFE_CALL" + } + + override fun originalDescription(whenExpr: IrWhen): String = "?." + + override fun variants(whenExpr: IrWhen, context: MutationContext): List { + val nonNullAccess = whenExpr.branches[0].result ?: return emptyList() + return listOf( + MutationOperator.Variant("a!!.b") { + nonNullAccess.deepCopyWithSymbols() + } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/StringLiteralOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/StringLiteralOperator.kt new file mode 100644 index 0000000..909e022 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/StringLiteralOperator.kt @@ -0,0 +1,55 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.isString + +/** + * Mutation operator for string literal mutations. + * + * Replaces a non-empty string literal with the empty string, and an empty + * string literal with a filled value. This detects tests that don't verify + * the actual string content (only checking for non-empty / non-null). + * + * Mirrors Stryker's `StringLiteral` mutator and pitest's `EMPTY_RETURNS` for + * strings. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class StringLiteralOperator : ConstMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "STRING_LITERAL", + name = "StringLiteral", + description = "Replace string literal with empty string", + group = MutatorGroup.STRING, + status = MutatorStatus.STABLE + ) + + override fun matches(const: IrConst): Boolean = + const.type.isString() && const.value is String + + override fun originalDescription(const: IrConst): String = + "\"${const.value}\"" + + override fun variants(const: IrConst, context: MutationContext): List { + val value = const.value as? String ?: return emptyList() + val stringType = context.pluginContext.irBuiltIns.stringType + + return if (value.isEmpty()) { + // Empty string → filled value + listOf( + MutationOperator.Variant("\"A\"") { + IrConstImpl.string(const.startOffset, const.endOffset, stringType, "A") + } + ) + } else { + // Non-empty string → empty string + listOf( + MutationOperator.Variant("\"\"") { + IrConstImpl.string(const.startOffset, const.endOffset, stringType, "") + } + ) + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/StringMethodOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/StringMethodOperator.kt new file mode 100644 index 0000000..4a9ab11 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/StringMethodOperator.kt @@ -0,0 +1,158 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.declarations.IrFile +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.impl.IrConstImpl +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.types.isString +import org.jetbrains.kotlin.ir.util.classId +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.Name + +/** + * Mutation operator for String method calls. + * + * Mirrors Stryker's `StringMethod` / `StringMethodToConstant` mutators: + * - `s.endsWith(x)` ↔ `s.startsWith(x)` + * - `s.toUpperCase()` ↔ `s.toLowerCase()` + * - `s.trim()` → `""` (StringMethodToConstant) + * + * Matched by receiver type `String` + method name, since these are regular + * (origin == null) member calls with no dedicated IrStatementOrigin. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class StringMethodOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "STRING_METHOD", + name = "StringMethod", + description = "Swap endsWith↔startsWith, toUpperCase↔toLowerCase; trim→\"\"", + group = MutatorGroup.STRING, + status = MutatorStatus.STABLE + ) + + private companion object { + /** name -> replacement name (also matching `$default` synthetic variants) */ + private val SWAPS = mapOf( + "endsWith" to "startsWith", + "startsWith" to "endsWith", + "toUpperCase" to "toLowerCase", + "toLowerCase" to "toUpperCase", + "uppercase" to "lowercase", + "lowercase" to "uppercase" + ) + + /** methods whose variant replaces the result with a constant */ + private val TO_CONSTANT = setOf("trim", "trimStart", "trimEnd") + + /** + * Returns the "base" method name for a call, stripping the synthetic + * `$default` suffix that Kotlin adds when default parameters are present. + */ + private fun baseName(name: String): String = + name.removeSuffix("\$default") + } + + override fun matches(call: IrCall): Boolean { + val name = call.symbol.owner.name.asString() + if (!receiverTypeIsString(call)) return false + return baseName(name) in SWAPS || baseName(name) in TO_CONSTANT + } + + /** True if the call's receiver (dispatch or extension) is a String. */ + private fun receiverTypeIsString(call: IrCall): Boolean { + call.dispatchReceiver?.let { return it.type.isString() } + // Extension functions: the receiver is the first parameter (kind ExtensionReceiver). + val params = call.symbol.owner.parameters + val extParam = params.firstOrNull { it.kind == org.jetbrains.kotlin.ir.declarations.IrParameterKind.ExtensionReceiver } + return extParam?.type?.isString() == true + } + + /** Returns the receiver expression (dispatch receiver, or the extension receiver arg). */ + private fun receiverOf(call: IrCall): IrExpression? { + call.dispatchReceiver?.let { return it } + // For extension functions the receiver is the first argument. + return call.arguments.getOrNull(0) + } + + override fun originalDescription(call: IrCall): String = baseName(call.symbol.owner.name.asString()) + + override fun variants(call: IrCall, context: MutationContext): List { + val receiver = receiverOf(call) ?: return emptyList() + val name = baseName(call.symbol.owner.name.asString()) + + // trim() → "" : replace the whole call with an empty string constant. + if (name in TO_CONSTANT) { + val stringType = call.type + return listOf( + MutationOperator.Variant("\"\"") { + IrConstImpl.string(call.startOffset, call.endOffset, stringType, "") + } + ) + } + + // endsWith↔startsWith, toUpperCase↔toLowerCase + val replacementName = SWAPS[name] ?: return emptyList() + val replacementFn = findFunction(call, replacementName, context) ?: return emptyList() + // The extension receiver is argument 0; the remaining args are the value args. + val argStart = if (call.dispatchReceiver == null) 1 else 0 + val args = call.arguments.drop(argStart).map { it?.deepCopyWithSymbols() } + + return listOf( + MutationOperator.Variant(replacementName) { + context.builder.irCall(replacementFn).also { newCall -> + if (call.dispatchReceiver != null) { + newCall.dispatchReceiver = receiver.deepCopyWithSymbols() + args.forEachIndexed { i, a -> newCall.arguments[i] = a } + } else { + // Extension: set extension receiver (arg 0) then value args. + newCall.arguments[0] = receiver.deepCopyWithSymbols() + args.forEachIndexed { i, a -> newCall.arguments[i + 1] = a } + } + } + } + ) + } + + private fun findFunction( + original: IrCall, + name: String, + context: MutationContext + ): IrSimpleFunctionSymbol? { + val owner = original.symbol.owner + // Extension functions (endsWith, startsWith, uppercase, ...) carry an + // ExtensionReceiver parameter even when owner.parent reports a receiver + // class (fake override). Detect them first so we don't route through + // `referenceFunctions` (which can fail outside a full IDE/compiler + // environment when deserializing stdlib classes). + val isExtension = owner.parameters.any { + it.kind == org.jetbrains.kotlin.ir.declarations.IrParameterKind.ExtensionReceiver + } + if (isExtension) { + val packageFragment = (owner.parent as? org.jetbrains.kotlin.ir.declarations.IrPackageFragment) + ?: ((owner.parent as? org.jetbrains.kotlin.ir.declarations.IrDeclaration) + ?.parent as? org.jetbrains.kotlin.ir.declarations.IrPackageFragment) + ?: return null + return packageFragment.declarations + .filterIsInstance() + .flatMap { it.declarations.asSequence() } + .filterIsInstance() + .firstOrNull { it.name.asString() == name } + ?.symbol + } + // Member functions (toUpperCase, ...) have the class as parent. + val parentClass = owner.parent as? org.jetbrains.kotlin.ir.declarations.IrClass + val parentClassId = parentClass?.classId + if (parentClassId != null) { + return context.pluginContext.referenceFunctions(CallableId(parentClassId, Name.identifier(name))) + .firstOrNull() + } + return null + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/SwitchOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/SwitchOperator.kt new file mode 100644 index 0000000..6b155e4 --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/SwitchOperator.kt @@ -0,0 +1,110 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrBranch +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.expressions.impl.IrBranchImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrElseBranchImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrWhenImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator for subject `when` expressions (`switch`). + * + * In common IR a subject `when (x) { ... }` lowers to an [IrWhen] with + * origin [IrStatementOrigin.WHEN]: each case is a branch whose condition is an + * `EQEQ` comparison, and the trailing `else` is a branch whose condition is a + * `true` constant. This operator produces: + * - **case swap** — swap the first two case branches (pitest `SWITCH_MUTATOR`), + * - **remove first case** — drop the first case branch so its subject falls + * through to the next matching case or the `else` (pitest `REMOVE_SWITCH`). + * + * Experimental: reordering switch branches can be high-noise, and the variants + * require at least two non-else branches. Must be opted into explicitly. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class SwitchOperator : WhenMutationOperator { + + override val descriptor = MutatorDescriptor( + id = "SWITCH", + name = "Switch", + description = "Swap / remove when (switch) branches", + group = MutatorGroup.CONTROL_FLOW, + status = MutatorStatus.EXPERIMENTAL + ) + + override fun matches(whenExpr: IrWhen): Boolean { + if (whenExpr.origin != IrStatementOrigin.WHEN) return false + return nonElseBranches(whenExpr).size >= 2 + } + + override fun originalDescription(whenExpr: IrWhen): String = "when" + + /** Returns the branches that are not the trailing `else` (true-const condition). */ + private fun nonElseBranches(whenExpr: IrWhen): List { + val branches = whenExpr.branches + // The else branch has a `true` constant condition (or is an IrElseBranchImpl). + return branches.filterNot { isElse(it) } + } + + private fun isElse(branch: IrBranch): Boolean { + val cond = branch.condition + val isTrueConst = cond is org.jetbrains.kotlin.ir.expressions.IrConst && + cond.value == true + return branch is IrElseBranchImpl || isTrueConst + } + + override fun variants(whenExpr: IrWhen, context: MutationContext): List { + val branches = whenExpr.branches + val nonElse = nonElseBranches(whenExpr) + if (nonElse.size < 2) return emptyList() + + val resultType = whenExpr.type + + // Variant 1: swap the first two case branches. + val swap = MutationOperator.Variant("swap first two cases") { + val newBranches = branches.toMutableList() + val i = branches.indexOf(nonElse[0]) + val j = branches.indexOf(nonElse[1]) + val tmp = newBranches[i] + newBranches[i] = newBranches[j] + newBranches[j] = tmp + rebuildWhen(whenExpr, newBranches) + } + + // Variant 2: remove the first case branch entirely. + val remove = MutationOperator.Variant("remove first case") { + val idx = branches.indexOf(nonElse[0]) + val newBranches = branches.toMutableList().apply { removeAt(idx) } + rebuildWhen(whenExpr, newBranches) + } + + return listOf(swap, remove) + } + + private fun rebuildWhen(original: IrWhen, branches: List): IrWhen = + IrWhenImpl( + startOffset = original.startOffset, + endOffset = original.endOffset, + type = original.type, + origin = null + ).apply { + for (branch in branches) { + when (branch) { + is IrElseBranchImpl -> this.branches += IrElseBranchImpl( + branch.startOffset, + branch.endOffset, + branch.condition.deepCopyWithSymbols(), + branch.result.deepCopyWithSymbols() + ) + else -> this.branches += IrBranchImpl( + branch.startOffset, + branch.endOffset, + branch.condition.deepCopyWithSymbols(), + branch.result.deepCopyWithSymbols() + ) + } + } + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/UnaryMinusOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/UnaryMinusOperator.kt new file mode 100644 index 0000000..4980bfe --- /dev/null +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/UnaryMinusOperator.kt @@ -0,0 +1,46 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols + +/** + * Mutation operator for unary minus removal: `-a` → `a`. + * + * In Kotlin IR, `-a` is a call to `unaryMinus()` with the operand as its dispatch + * receiver. The call carries no [IrStatementOrigin] (origin is null), so it is + * matched by function name. The mutation replaces the call with the operand itself. + * + * Mirrors pitest's `INVERT_NEGS` and Mull's `cxx_minus_to_noop`. + * + * Note: negative literals like `-5` are represented as a constant, not a + * `unaryMinus` call, so they are not matched (mutating `-5` to `5` is covered by + * the constant boundary operator instead). + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +class UnaryMinusOperator : MutationOperator { + + override val descriptor = MutatorDescriptor( + id = "UNARY_MINUS", + name = "UnaryMinus", + description = "Remove unary minus: -a → a", + group = MutatorGroup.ARITHMETIC, + status = MutatorStatus.STABLE + ) + + override fun matches(call: IrCall): Boolean { + return call.symbol.owner.name.asString() == "unaryMinus" + } + + override fun originalDescription(call: IrCall): String = "-" + + override fun variants(call: IrCall, context: MutationContext): List { + val operand = call.dispatchReceiver ?: return emptyList() + return listOf( + MutationOperator.Variant("noop") { + operand.deepCopyWithSymbols() + } + ) + } +} diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/VoidFunctionBodyOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/VoidFunctionBodyOperator.kt index 2142831..dbb063b 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/VoidFunctionBodyOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/VoidFunctionBodyOperator.kt @@ -31,6 +31,14 @@ import org.jetbrains.kotlin.ir.util.isPropertyAccessor */ class VoidFunctionBodyOperator : FunctionBodyMutationOperator { + override val descriptor = MutatorDescriptor( + id = "VOID_FUNCTION_BODY", + name = "VoidFunctionBody", + description = "Empty the body of Unit-returning functions", + group = MutatorGroup.CALL, + status = MutatorStatus.STABLE + ) + override fun matches(function: IrSimpleFunction): Boolean { if (!function.returnType.isUnit()) return false if (function.isPropertyAccessor) return false diff --git a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/WhenMutationOperator.kt b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/WhenMutationOperator.kt index 31951ff..5cfdc22 100644 --- a/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/WhenMutationOperator.kt +++ b/mutflow-compiler-plugin/src/main/kotlin/io/github/anschnapp/mutflow/compiler/WhenMutationOperator.kt @@ -11,6 +11,11 @@ import org.jetbrains.kotlin.ir.expressions.IrWhen */ interface WhenMutationOperator { + /** + * Declarative metadata for this operator (stable id, group, status). + */ + val descriptor: MutatorDescriptor + /** * Returns true if this operator can generate mutations for the given when expression. */ diff --git a/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/IrTestCompiler.kt b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/IrTestCompiler.kt new file mode 100644 index 0000000..63e7def --- /dev/null +++ b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/IrTestCompiler.kt @@ -0,0 +1,189 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler +import org.jetbrains.kotlin.cli.common.ExitCode +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream + +/** + * Test helper that compiles a Kotlin source snippet to IR in-process and returns + * the resulting [IrModuleFragment] together with the [IrPluginContext], so + * operator tests can walk the IR and assert on generated variants. + * + * The IR is captured by a test-only compiler plugin ([TestIrCaptureRegistrar]) + * loaded by the in-process [K2JVMCompiler] through the `-Xplugin` mechanism. The + * registrar registers an [IrGenerationExtension] that records the module fragment + * and plugin context when the compiler reaches the IR generation phase. + */ +object IrTestCompiler { + + private var capturedModule: IrModuleFragment? = null + private var capturedContext: IrPluginContext? = null + + /** Called by [TestIrCaptureRegistrar] when the compiler reaches IR generation. */ + fun capture(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { + capturedModule = moduleFragment + capturedContext = pluginContext + } + + /** A compiled module plus the plugin context needed to build [MutationContext]. */ + data class CompiledModule( + val module: IrModuleFragment, + val pluginContext: IrPluginContext + ) { + /** + * Builds a [MutationContext] for the named top-level function, suitable for + * calling operator `variants(...)`. + */ + @OptIn(UnsafeDuringIrConstructionAPI::class) + fun contextFor(functionName: String): MutationContext { + val fn = findFunction(functionName) + return MutationContext( + pluginContext = pluginContext, + builder = DeclarationIrBuilder(pluginContext, fn.symbol), + containingFunction = fn + ) + } + + /** Finds the first top-level function with the given name. */ + fun findFunction(functionName: String): IrSimpleFunction { + val functions = mutableListOf() + module.acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitSimpleFunction(declaration: IrSimpleFunction) { + if (declaration.name.asString() == functionName) { + functions += declaration + } + super.visitSimpleFunction(declaration) + } + }) + return functions.firstOrNull() + ?: error("Function '$functionName' not found in compiled module") + } + } + + /** + * Compiles [source] to IR. + * + * @param source Kotlin source code (a single file). + * @param fileName name used for the temp source file (affects reported locations). + * @param extraPlugins additional `-Xplugin` classpaths to load alongside the capture registrar. + */ + fun compile( + source: String, + fileName: String = "Test.kt", + extraPlugins: List = emptyList(), + extraClasspath: List = emptyList(), + extraArgs: List = emptyList() + ): CompiledModule { + capturedModule = null + capturedContext = null + + val sourceFile = File.createTempFile("mutflow-ir-test", ".kt").apply { + writeText(source) + } + val outputDir = File.createTempFile("mutflow-ir-out", "").apply { + delete() + mkdir() + } + val pluginDir = createPluginDir() + + val stdlibJar = findStdlibJar() + val classpath = (listOf(stdlibJar) + extraClasspath).joinToString(File.pathSeparator) + val err = ByteArrayOutputStream() + val pluginArgs = listOf("-Xplugin=${pluginDir.path}") + extraPlugins.map { "-Xplugin=$it" } + val exitCode = K2JVMCompiler().exec( + PrintStream(err), + *pluginArgs.toTypedArray(), + *extraArgs.toTypedArray(), + "-classpath", classpath, + "-d", outputDir.path, + sourceFile.path + ) + + sourceFile.delete() + outputDir.deleteRecursively() + pluginDir.deleteRecursively() + + if (exitCode != ExitCode.OK) { + error("Compilation failed ($exitCode):\n${err.toString()}") + } + + val module = capturedModule ?: error("IR was not captured (compiler did not reach IR generation phase)") + val context = capturedContext ?: error("Plugin context was not captured") + return CompiledModule(module, context) + } + + /** + * Creates a temp directory that acts as a compiler plugin classpath: it holds + * the ServiceLoader registration for [TestIrCaptureRegistrar] under + * `META-INF/services`. The in-process compiler loads registrars from the + * `-Xplugin` classpath, and the registrar class itself resolves through the + * parent (test) classloader. + */ + private fun createPluginDir(): File = createRegistrarPluginDir( + "io.github.anschnapp.mutflow.compiler.TestIrCaptureRegistrar" + ) + + /** + * Same trick as [createPluginDir], but for the real [MutflowCompilerPluginRegistrar] + * (also on the test classloader, since it lives in this module's main source set). + * Used by end-to-end regression tests that need the actual mutation transformation + * to run, not just IR capture. + */ + fun createRealPluginDir(): File = createRegistrarPluginDir( + "io.github.anschnapp.mutflow.compiler.MutflowCompilerPluginRegistrar" + ) + + private fun createRegistrarPluginDir(registrarFqName: String): File { + val dir = File.createTempFile("mutflow-ir-plugin", "").apply { + delete() + mkdir() + } + val servicesDir = File(dir, "META-INF/services").apply { mkdirs() } + File(servicesDir, "org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar") + .writeText(registrarFqName) + return dir + } + + /** + * Locates the kotlin-stdlib jar on the test classpath. The compiler needs it + * to resolve builtins (Int.plus, Boolean.not, ...) referenced by the operators. + */ + private fun findStdlibJar(): String { + val classpath = System.getProperty("java.class.path") + return classpath.split(File.pathSeparator) + .firstOrNull { it.contains("kotlin-stdlib") && it.endsWith(".jar") } + ?: error("kotlin-stdlib jar not found on test classpath") + } + + /** + * Locates a project module's compiled classes/jar on the test classpath by a + * distinguishing substring (e.g. "mutflow-annotations", "mutflow-core"). Needed + * for end-to-end tests whose source references those modules' types. + */ + fun findProjectClasspathEntry(moduleNameFragment: String): String { + val classpath = System.getProperty("java.class.path") + return classpath.split(File.pathSeparator) + .filter { it.contains(moduleNameFragment) } + .let { candidates -> + // Prefer a jvm-target entry over js/wasm/native/metadata ones when the + // module is multiplatform and multiple targets are on the classpath. + candidates.firstOrNull { "jvm" in it } ?: candidates.firstOrNull() + } + ?: error("$moduleNameFragment not found on test classpath") + } +} diff --git a/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/MutationCatalogTest.kt b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/MutationCatalogTest.kt new file mode 100644 index 0000000..6e6547e --- /dev/null +++ b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/MutationCatalogTest.kt @@ -0,0 +1,154 @@ +package io.github.anschnapp.mutflow.compiler + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies the declarative mutation catalog: every operator is registered with a + * stable, unique id, correct group/status, and the transformer defaults match. + */ +class MutationCatalogTest { + + @Test + fun `all operators are registered`() { + // 13 original + RemoveIncrement + ArgumentPropagation + assertEquals(15, MutationCatalog.callOperators.size) + assertEquals(5, MutationCatalog.returnOperators.size) + assertEquals(1, MutationCatalog.functionBodyOperators.size) + // 4 original + Switch + assertEquals(5, MutationCatalog.whenOperators.size) + assertEquals(2, MutationCatalog.constOperators.size) + // ConstructorCall + RegexPattern + assertEquals(2, MutationCatalog.constructorCallOperators.size) + assertEquals(1, MutationCatalog.assignmentOperators.size) + // 27 original + 4 new = 31 raw, minus 1 for the BooleanLogic descriptor that + // appears in both the call and when lists (deduped by distinct() because + // MutatorDescriptor is a data class and the two instances produce equal values). + assertEquals(30, MutationCatalog.allDescriptors.size) + } + + @Test + fun `ids are unique and non-blank`() { + val ids = MutationCatalog.allDescriptors.map { it.id } + assertEquals(ids.size, ids.toSet().size, "mutator ids must be unique") + assertTrue(ids.all { it.isNotBlank() }) + } + + @Test + fun `validate passes for the current catalog`() { + MutationCatalog.validate() // should not throw + } + + @Test + fun `every descriptor has name and description`() { + MutationCatalog.allDescriptors.forEach { d -> + assertTrue(d.name.isNotBlank(), "name blank for ${d.id}") + assertTrue(d.description.isNotBlank(), "description blank for ${d.id}") + } + } + + @Test + fun `byId finds registered operators`() { + assertNotNull(MutationCatalog.byId("RELATIONAL_COMPARISON")) + assertNotNull(MutationCatalog.byId("ARITHMETIC_SWAP")) + assertNotNull(MutationCatalog.byId("CONSTANT_BOUNDARY")) + assertNotNull(MutationCatalog.byId("EQUALITY_SWAP")) + assertNotNull(MutationCatalog.byId("BOOLEAN_INVERSION")) + assertNotNull(MutationCatalog.byId("BOOLEAN_LOGIC")) + assertNotNull(MutationCatalog.byId("RETURN_BOOLEAN")) + assertNotNull(MutationCatalog.byId("RETURN_NULLABLE")) + assertNotNull(MutationCatalog.byId("RETURN_PRIMITIVE")) + assertNotNull(MutationCatalog.byId("RETURN_OBJECT")) + assertNotNull(MutationCatalog.byId("UNARY_MINUS")) + assertNotNull(MutationCatalog.byId("BITWISE_SWAP")) + assertNotNull(MutationCatalog.byId("INCREMENT")) + assertNotNull(MutationCatalog.byId("FORCE_CONDITIONAL")) + assertNotNull(MutationCatalog.byId("STRING_LITERAL")) + assertNotNull(MutationCatalog.byId("BOOLEAN_CONST")) + assertNotNull(MutationCatalog.byId("NON_VOID_CALL")) + assertNotNull(MutationCatalog.byId("VOID_FUNCTION_BODY")) + assertNotNull(MutationCatalog.byId("CONSTRUCTOR_CALL")) + assertNotNull(MutationCatalog.byId("REMOVE_INCREMENT")) + assertNotNull(MutationCatalog.byId("STRING_METHOD")) + assertNotNull(MutationCatalog.byId("COLLECTION_METHOD")) + assertNotNull(MutationCatalog.byId("REFERENCE_EQUALITY_SWAP")) + assertNotNull(MutationCatalog.byId("ELVIS")) + assertNotNull(MutationCatalog.byId("SAFE_CALL")) + assertNotNull(MutationCatalog.byId("RETURN_EMPTY_COLLECTION")) + assertNotNull(MutationCatalog.byId("ASSIGN_CONST")) + assertNotNull(MutationCatalog.byId("REMOVE_INCREMENT")) + assertNotNull(MutationCatalog.byId("ARGUMENT_PROPAGATION")) + assertNotNull(MutationCatalog.byId("SWITCH")) + assertNotNull(MutationCatalog.byId("REGEX_PATTERN")) + } + + @Test + fun `byId returns null for unknown id`() { + assertNull(MutationCatalog.byId("DOES_NOT_EXIST")) + } + + @Test + fun `byGroup groups operators correctly`() { + assertEquals(3, MutationCatalog.byGroup(MutatorGroup.RELATIONAL).size) // RelationalComparison + EqualitySwap + ReferenceEqualitySwap + assertEquals(6, MutationCatalog.byGroup(MutatorGroup.ARITHMETIC).size) // ArithmeticSwap + UnaryMinus + BitwiseSwap + Increment + RemoveIncrement + AssignConst + assertEquals(3, MutationCatalog.byGroup(MutatorGroup.BOOLEAN).size) // BooleanInversion + BooleanLogic + BooleanConst + assertEquals(1, MutationCatalog.byGroup(MutatorGroup.CONSTANT).size) + assertEquals(5, MutationCatalog.byGroup(MutatorGroup.RETURN).size) + assertEquals(4, MutationCatalog.byGroup(MutatorGroup.CALL).size) // ReplaceNonVoid + ConstructorCall + VoidFunctionBody + ArgumentPropagation + assertEquals(2, MutationCatalog.byGroup(MutatorGroup.CONTROL_FLOW).size) // ForceConditional + Switch + assertEquals(2, MutationCatalog.byGroup(MutatorGroup.KOTLIN_SPECIFIC).size) // Elvis + SafeCall + assertEquals(2, MutationCatalog.byGroup(MutatorGroup.STRING).size) // StringLiteral + StringMethod + assertEquals(1, MutationCatalog.byGroup(MutatorGroup.COLLECTION).size) + assertEquals(1, MutationCatalog.byGroup(MutatorGroup.REGEX).size) // RegexPattern + } + + @Test + fun `stable descriptors exclude experimental`() { + assertEquals(26, MutationCatalog.stableDescriptors.size) + assertTrue(MutationCatalog.stableDescriptors.all { it.status == MutatorStatus.STABLE }) + assertTrue(MutationCatalog.allDescriptors.any { it.status == MutatorStatus.EXPERIMENTAL }) + } + + @Test + fun `transformer defaults match the catalog`() { + assertEquals( + MutationCatalog.callOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultCallOperators().map { it.descriptor.id } + ) + assertEquals( + MutationCatalog.returnOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultReturnOperators().map { it.descriptor.id } + ) + assertEquals( + MutationCatalog.functionBodyOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultFunctionBodyOperators().map { it.descriptor.id } + ) + assertEquals( + MutationCatalog.whenOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultWhenOperators().map { it.descriptor.id } + ) + assertEquals( + MutationCatalog.constOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultConstOperators().map { it.descriptor.id } + ) + assertEquals( + MutationCatalog.constructorCallOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultConstructorCallOperators().map { it.descriptor.id } + ) + assertEquals( + MutationCatalog.assignmentOperators.map { it.descriptor.id }, + MutflowIrTransformer.defaultAssignmentOperators().map { it.descriptor.id } + ) + } + + @Test + fun `descriptors are stable across instances`() { + // Two instances of the same operator must report identical descriptors. + val a = RelationalComparisonOperator().descriptor + val b = RelationalComparisonOperator().descriptor + assertEquals(a, b) + } +} diff --git a/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/NewOperatorVariantTest.kt b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/NewOperatorVariantTest.kt new file mode 100644 index 0000000..d8327b7 --- /dev/null +++ b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/NewOperatorVariantTest.kt @@ -0,0 +1,171 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConstructorCall +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for the newly implemented experimental operators: ArgumentPropagation, + * Switch, and RegexPattern. + */ +class NewOperatorVariantTest { + + private fun compile(source: String): IrTestCompiler.CompiledModule = IrTestCompiler.compile(source) + + private fun IrModuleFragment.collectCalls(): List { + val calls = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitCall(expression: IrCall) { + calls += expression + super.visitCall(expression) + } + }) + return calls + } + + private fun IrModuleFragment.collectWhens(): List { + val whens = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitWhen(expression: IrWhen) { + whens += expression + super.visitWhen(expression) + } + }) + return whens + } + + private fun IrModuleFragment.collectConstructorCalls(): List { + val ctors = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitConstructorCall(expression: IrConstructorCall) { + ctors += expression + super.visitConstructorCall(expression) + } + }) + return ctors + } + + // --- ArgumentPropagationOperator --- + + @Test + fun `argument propagation on same-typed args`() { + val compiled = compile( + "fun g(a: Int, b: Int, c: Int): Int = a\n" + + "fun f(x: Int, y: Int): Int = g(x, y, x)" + ) + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "g" } + val operator = ArgumentPropagationOperator() + + assertTrue(operator.matches(call)) + // x (idx0), y (idx1), x (idx2): first two value args (idx0=x, idx1=y) are same-typed, + // so we get "propagate x→1" and "propagate y→0". + assertEquals( + listOf("g(arg->1)", "g(arg->0)"), + operator.variants(call, compiled.contextFor("f")).map { it.description } + ) + } + + @Test + fun `argument propagation requires two same-typed value args`() { + // Single argument call -> no mutation. + val compiled = compile("fun g(a: Int): Int = a\nfun f(x: Int): Int = g(x)") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "g" } + val operator = ArgumentPropagationOperator() + + assertTrue(!operator.matches(call), "single-arg call must not match") + } + + @Test + fun `argument propagation skips operator calls`() { + // Operator calls have a non-null origin and must be skipped. + val compiled = compile("fun f(a: Int, b: Int): Int = a + b") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "plus" } + val operator = ArgumentPropagationOperator() + + assertTrue(!operator.matches(call), "operator call must not match") + } + + // --- SwitchOperator --- + + @Test + fun `switch generates swap and remove variants`() { + val compiled = compile( + "fun f(x: Int): Int = when (x) { 1 -> 10; 2 -> 20; else -> 0 }" + ) + val whenExpr = compiled.module.collectWhens().first { it.origin?.debugName == "WHEN" } + val operator = SwitchOperator() + + assertTrue(operator.matches(whenExpr)) + assertEquals( + listOf("swap first two cases", "remove first case"), + operator.variants(whenExpr, compiled.contextFor("f")).map { it.description } + ) + } + + @Test + fun `switch does not match if-when`() { + val compiled = compile("fun f(a: Boolean): Int = if (a) 1 else 2") + val operator = SwitchOperator() + val whens = compiled.module.collectWhens() + assertTrue(whens.none { operator.matches(it) }, "Switch must not match an if") + } + + // --- RegexPatternOperator --- + + @Test + fun `regex pattern mutates anchors`() { + val compiled = compile( + "fun f(s: String): Boolean = Regex(\"^abc\").containsMatchIn(s)" + ) + val ctor = compiled.module.collectConstructorCalls().first { + it.symbol.owner.parent is org.jetbrains.kotlin.ir.declarations.IrClass + } + val operator = RegexPatternOperator() + + assertTrue(operator.matches(ctor)) + val variants = operator.variants(ctor, compiled.contextFor("f")).map { it.description } + // Removing the leading ^ yields "abc". + assertTrue(variants.any { it.contains("abc") }, "expected an anchor-removed variant, got: $variants") + } + + @Test + fun `regex pattern mutator only matches string literal patterns`() { + val compiled = compile( + "fun f(): String = Regex(\"^a\").pattern" + ) + val operator = RegexPatternOperator() + val ctors = compiled.module.collectConstructorCalls() + // The pattern is a string literal so it matches. + assertTrue(ctors.any { operator.matches(it) }, "Regex ctor with literal pattern must match") + } + + @Test + fun `regex pattern mutator mutatePattern rewrites`() { + val operator = RegexPatternOperator() + val mutated = operator.mutatePattern("^[ab]+$") + // Leading ^ removed, trailing $ removed, class [ab]->[^ab], quantifier + removed. + assertTrue(mutated.contains("^[^ab]+$"), "expected class-negate variant, got: $mutated") + assertTrue(mutated.contains("^[ab]+"), "expected anchor-removed variants, got: $mutated") + assertTrue(mutated.contains("^[ab]$"), "expected quantifier-removed variant, got: $mutated") + assertTrue(mutated.contains("[ab]+$"), "expected leading-anchor-removed variant, got: $mutated") + } +} diff --git a/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/OperatorVariantTest.kt b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/OperatorVariantTest.kt new file mode 100644 index 0000000..e88aea6 --- /dev/null +++ b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/OperatorVariantTest.kt @@ -0,0 +1,857 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrConstructorCall +import org.jetbrains.kotlin.ir.expressions.IrReturn +import org.jetbrains.kotlin.ir.expressions.IrSetValue +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.types.isBoolean +import org.jetbrains.kotlin.ir.types.isInt +import org.jetbrains.kotlin.ir.types.isString +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Feeds IR snippets through the mutation operators and asserts the generated + * variants, per PLAN Phase 1 ("write unit tests that feed IR snippets and assert + * correct variants are generated"). + */ +class OperatorVariantTest { + + private fun compile(source: String): IrTestCompiler.CompiledModule = IrTestCompiler.compile(source) + + /** Collects all IrCall nodes in the module. */ + private fun IrModuleFragment.collectCalls(): List { + val calls = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitCall(expression: IrCall) { + calls += expression + super.visitCall(expression) + } + }) + return calls + } + + /** Collects all IrReturn nodes in the module. */ + private fun IrModuleFragment.collectReturns(): List { + val returns = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitReturn(expression: IrReturn) { + returns += expression + super.visitReturn(expression) + } + }) + return returns + } + + /** Collects all IrWhen nodes in the module. */ + private fun IrModuleFragment.collectWhens(): List { + val whens = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitWhen(expression: IrWhen) { + whens += expression + super.visitWhen(expression) + } + }) + return whens + } + + /** Collects all IrConst nodes in the module. */ + private fun IrModuleFragment.collectConsts(): List { + val consts = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitConst(expression: IrConst) { + consts += expression + super.visitConst(expression) + } + }) + return consts + } + + /** Collects all IrConstructorCall nodes in the module. */ + private fun IrModuleFragment.collectConstructorCalls(): List { + val calls = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitConstructorCall(expression: IrConstructorCall) { + calls += expression + super.visitConstructorCall(expression) + } + }) + return calls + } + + /** Collects all IrSetValue (variable assignment) nodes in the module. */ + private fun IrModuleFragment.collectSetValues(): List { + val setValues = mutableListOf() + acceptChildrenVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitSetValue(expression: IrSetValue) { + setValues += expression + super.visitSetValue(expression) + } + }) + return setValues + } + + // --- RelationalComparisonOperator --- + + @Test + fun `relational comparison generates boundary and flip variants`() { + val compiled = compile("fun f(a: Int, b: Int) = a > b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.GT } + val operator = RelationalComparisonOperator() + + assertTrue(operator.matches(call)) + assertEquals(">", operator.originalDescription(call)) + // > → >= (boundary) and > → < (flip) + assertEquals(listOf(">=", "<"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `relational comparison on less-than`() { + val compiled = compile("fun f(a: Int, b: Int) = a < b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.LT } + val operator = RelationalComparisonOperator() + + assertEquals(listOf("<=", ">"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- ArithmeticOperator --- + + @Test + fun `arithmetic plus swaps to minus`() { + val compiled = compile("fun f(a: Int, b: Int) = a + b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.PLUS } + val operator = ArithmeticOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("-"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `arithmetic multiply swaps to safe division`() { + val compiled = compile("fun f(a: Int, b: Int) = a * b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.MUL } + val operator = ArithmeticOperator() + + assertEquals(listOf("/"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- EqualitySwapOperator --- + + @Test + fun `equality swap on equals`() { + val compiled = compile("fun f(a: Int, b: Int) = a == b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.EQEQ } + val operator = EqualitySwapOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("!="), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `equality swap skips null comparisons`() { + val compiled = compile("fun f(a: String?) = a == null") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.EQEQ } + val operator = EqualitySwapOperator() + + assertTrue(!operator.matches(call), "null comparison must be skipped") + } + + // --- ConstantBoundaryOperator --- + + @Test + fun `constant boundary on comparison with constant`() { + val compiled = compile("fun f(x: Int) = x > 0") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.GT } + val operator = ConstantBoundaryOperator() + + assertTrue(operator.matches(call)) + // 0 → 1 and 0 → -1 + assertEquals(listOf("1", "-1"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `constant boundary applies to float constants`() { + val compiled = compile("fun f(x: Double) = x > 1.0") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.GT } + val operator = ConstantBoundaryOperator() + + assertTrue(operator.matches(call), "float constant must be a valid boundary target") + // 1.0 → 2.0 and 1.0 → 0.0 + assertEquals(listOf("2.0", "0.0"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- UnaryMinusOperator --- + + @Test + fun `unary minus removes the negation`() { + val compiled = compile("fun f(a: Int) = -a") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "unaryMinus" } + val operator = UnaryMinusOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("noop"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- BitwiseOperator --- + + @Test + fun `bitwise and swaps to or`() { + val compiled = compile("fun f(a: Int, b: Int) = a and b") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "and" } + val operator = BitwiseOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("or"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `bitwise shl swaps to shr`() { + val compiled = compile("fun f(a: Int, b: Int) = a shl b") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "shl" } + val operator = BitwiseOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("shr"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `bitwise operator does not match boolean and`() { + val compiled = compile("fun f(a: Boolean, b: Boolean) = a and b") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "and" } + val operator = BitwiseOperator() + + assertTrue(!operator.matches(call), "Boolean and must not be treated as bitwise") + } + + // --- Return operators --- + + @Test + fun `boolean return generates true and false`() { + val compiled = compile("fun f(x: Int): Boolean { return x > 0 }") + val ret = compiled.module.collectReturns().first() + val operator = BooleanReturnOperator() + + assertTrue(operator.matches(ret)) + assertEquals(listOf("true", "false"), operator.variants(ret, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `primitive return generates zero`() { + val compiled = compile("fun f(x: Int): Int { return x + 1 }") + val ret = compiled.module.collectReturns().first() + val operator = PrimitiveReturnOperator() + + assertTrue(operator.matches(ret)) + assertEquals(listOf("0"), operator.variants(ret, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `primitive return does not match boolean return`() { + val compiled = compile("fun f(x: Int): Boolean { return x > 0 }") + val ret = compiled.module.collectReturns().first() + val operator = PrimitiveReturnOperator() + + assertTrue(!operator.matches(ret), "PrimitiveReturn must not match Boolean returns") + } + + @Test + fun `nullable return generates null`() { + val compiled = compile("fun f(x: Int): Int? { return x }") + val ret = compiled.module.collectReturns().first() + val operator = NullableReturnOperator() + + assertTrue(operator.matches(ret)) + assertEquals(listOf("null"), operator.variants(ret, compiled.contextFor("f")).map { it.description }) + } + + // --- IncrementOperator --- + + @Test + fun `increment swaps to decrement`() { + val compiled = compile("fun f(a: Int): Int { var x = a; x++; return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "" } + val operator = IncrementOperator() + + assertTrue(operator.matches(call)) + assertEquals("++", operator.originalDescription(call)) + assertEquals(listOf("--"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `decrement swaps to increment`() { + val compiled = compile("fun f(a: Int): Int { var x = a; x--; return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "" } + val operator = IncrementOperator() + + assertTrue(operator.matches(call)) + assertEquals("--", operator.originalDescription(call)) + assertEquals(listOf("++"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `increment does not match other calls`() { + val compiled = compile("fun f(a: Int, b: Int) = a + b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.PLUS } + val operator = IncrementOperator() + + assertTrue(!operator.matches(call), "Increment must not match arithmetic calls") + } + + // --- ObjectReturnOperator --- + + @Test + fun `object return generates null`() { + val compiled = compile("fun f(x: String): String { return x }") + val ret = compiled.module.collectReturns().first() + val operator = ObjectReturnOperator() + + assertTrue(operator.matches(ret)) + assertEquals(listOf("null"), operator.variants(ret, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `object return does not match primitive return`() { + val compiled = compile("fun f(x: Int): Int { return x }") + val ret = compiled.module.collectReturns().first() + val operator = ObjectReturnOperator() + + assertTrue(!operator.matches(ret), "ObjectReturn must not match primitive returns") + } + + @Test + fun `object return does not match nullable return`() { + val compiled = compile("fun f(x: String?): String? { return x }") + val ret = compiled.module.collectReturns().first() + val operator = ObjectReturnOperator() + + assertTrue(!operator.matches(ret), "ObjectReturn must not match nullable returns") + } + + // --- StringLiteralOperator --- + + @Test + fun `string literal generates empty string`() { + val compiled = compile("fun f(): String = \"hello\"") + val const = compiled.module.collectConsts().first { it.type.isString() } + val operator = StringLiteralOperator() + + assertTrue(operator.matches(const)) + assertEquals("\"hello\"", operator.originalDescription(const)) + assertEquals(listOf("\"\""), operator.variants(const, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `empty string literal generates filled string`() { + val compiled = compile("fun f(): String = \"\"") + val const = compiled.module.collectConsts().first { it.type.isString() } + val operator = StringLiteralOperator() + + assertTrue(operator.matches(const)) + assertEquals("\"\"", operator.originalDescription(const)) + assertEquals(listOf("\"A\""), operator.variants(const, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `string literal does not match numeric constants`() { + val compiled = compile("fun f(): Int = 42") + val const = compiled.module.collectConsts().first { it.type.isInt() } + val operator = StringLiteralOperator() + + assertTrue(!operator.matches(const), "StringLiteral must not match numeric constants") + } + + // --- ReplaceNonVoidCallOperator --- + + @Test + fun `non void call generates numeric default`() { + val compiled = compile("class C { fun m(): Int = 1 }\nfun f(c: C): Int { val x = c.m(); return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "m" } + val operator = ReplaceNonVoidCallOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("0"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `non void call on string generates empty string`() { + val compiled = compile("class C { fun m(): String = \"x\" }\nfun f(c: C): String { val x = c.m(); return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "m" } + val operator = ReplaceNonVoidCallOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf(""), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `non void call does not match operator calls`() { + val compiled = compile("fun f(a: Int, b: Int) = a + b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.PLUS } + val operator = ReplaceNonVoidCallOperator() + + assertTrue(!operator.matches(call), "ReplaceNonVoidCall must not match operator calls") + } + + @Test + fun `non void call does not match boolean calls`() { + val compiled = compile("class C { fun m(): Boolean = true }\nfun f(c: C): Boolean { val x = c.m(); return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "m" } + val operator = ReplaceNonVoidCallOperator() + + assertTrue(!operator.matches(call), "ReplaceNonVoidCall must not match boolean calls") + } + + // --- BooleanConstOperator --- + + @Test + fun `boolean const true flips to false`() { + val compiled = compile("fun f(): Boolean = true") + val const = compiled.module.collectConsts().first { it.type.isBoolean() } + val operator = BooleanConstOperator() + + assertTrue(operator.matches(const)) + assertEquals("true", operator.originalDescription(const)) + assertEquals(listOf("false"), operator.variants(const, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `boolean const false flips to true`() { + val compiled = compile("fun f(): Boolean = false") + val const = compiled.module.collectConsts().first { it.type.isBoolean() } + val operator = BooleanConstOperator() + + assertTrue(operator.matches(const)) + assertEquals("false", operator.originalDescription(const)) + assertEquals(listOf("true"), operator.variants(const, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `boolean const does not match string constants`() { + val compiled = compile("fun f(): String = \"x\"") + val const = compiled.module.collectConsts().first { it.type.isString() } + val operator = BooleanConstOperator() + + assertTrue(!operator.matches(const), "BooleanConst must not match string constants") + } + + // --- ForceConditionalOperator --- + + @Test + fun `force conditional generates true and false`() { + val compiled = compile("fun f(a: Int, b: Int): Int = if (a > b) a else b") + val whenExpr = compiled.module.collectWhens().first { it.origin == IrStatementOrigin.IF } + val operator = ForceConditionalOperator() + + assertTrue(operator.matches(whenExpr)) + assertEquals("if", operator.originalDescription(whenExpr)) + assertEquals(listOf("true", "false"), operator.variants(whenExpr, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `force conditional does not match when expressions`() { + val compiled = compile("fun f(x: Int): Int = when (x) { 1 -> 1; else -> 0 }") + val whenExpr = compiled.module.collectWhens().first { it.origin != IrStatementOrigin.IF } + val operator = ForceConditionalOperator() + + assertTrue(!operator.matches(whenExpr), "ForceConditional must not match when expressions") + } + + // --- BooleanLogicOperator (when-based) --- + + @Test + fun `boolean and swaps to or`() { + val compiled = compile("fun f(a: Boolean, b: Boolean) = a && b") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "ANDAND" } + val operator = BooleanLogicOperator() + + assertTrue(operator.matches(call)) + assertEquals(listOf("||"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- ConstructorCallOperator --- + + @Test + fun `constructor call generates null`() { + val compiled = compile("class Foo(val x: Int)\nfun f(): Foo = Foo(1)") + val ctor = compiled.module.collectConstructorCalls().first() + val operator = ConstructorCallOperator() + + assertTrue(operator.matches(ctor)) + assertEquals("", operator.originalDescription(ctor)) + assertEquals(listOf("null"), operator.variants(ctor, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `constructor call with no args generates null`() { + val compiled = compile("class Foo\nfun f(): Foo = Foo()") + val ctor = compiled.module.collectConstructorCalls().first() + val operator = ConstructorCallOperator() + + assertTrue(operator.matches(ctor)) + assertEquals(listOf("null"), operator.variants(ctor, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `constructor call inside expression generates null`() { + val compiled = compile("class Foo(val x: Int)\nfun f(): Int = Foo(42).x") + val ctor = compiled.module.collectConstructorCalls().first() + val operator = ConstructorCallOperator() + + assertTrue(operator.matches(ctor)) + assertEquals(listOf("null"), operator.variants(ctor, compiled.contextFor("f")).map { it.description }) + } + + // --- RemoveIncrementOperator --- + + @Test + fun `remove increment replaces with operand`() { + val compiled = compile("fun f(a: Int): Int { var x = a; x++; return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "" } + val operator = RemoveIncrementOperator() + + assertTrue(operator.matches(call)) + assertEquals("++", operator.originalDescription(call)) + assertEquals(listOf("noop"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `remove decrement replaces with operand`() { + val compiled = compile("fun f(a: Int): Int { var x = a; x--; return x }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "" } + val operator = RemoveIncrementOperator() + + assertTrue(operator.matches(call)) + assertEquals("--", operator.originalDescription(call)) + assertEquals(listOf("noop"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `remove increment does not match other calls`() { + val compiled = compile("fun f(a: Int, b: Int) = a + b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.PLUS } + val operator = RemoveIncrementOperator() + + assertTrue(!operator.matches(call), "RemoveIncrement must not match arithmetic calls") + } + + // --- ArithmeticOperator % → * variant --- + + @Test + fun `modulo swaps to division and multiplication`() { + val compiled = compile("fun f(a: Int, b: Int) = a % b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.PERC } + val operator = ArithmeticOperator() + + assertEquals(listOf("/", "*"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- BitwiseOperator xor → or additional variant --- + + @Test + fun `bitwise xor swaps to and and or`() { + val compiled = compile("fun f(a: Int, b: Int) = a xor b") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "xor" } + val operator = BitwiseOperator() + + assertEquals(listOf("and", "or"), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- StringMethodOperator --- + + @Test + fun `string endsWith swaps to startsWith`() { + val compiled = compile("fun f(s: String) = s.endsWith(\"x\")") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString().startsWith("endsWith") } + val operator = StringMethodOperator() + + // matches() and the display name are verifiable in the isolated test compiler; + // full variant generation requires stdlib deserialization which the isolated + // harness cannot provide (verified end-to-end in mutflow-test-kmp). + assertTrue(operator.matches(call)) + assertEquals("endsWith", operator.originalDescription(call)) + } + + @Test + fun `string trim replaces with empty string`() { + val compiled = compile("fun f(s: String) = s.trim()") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "trim" } + val operator = StringMethodOperator() + + assertTrue(operator.matches(call)) + assertEquals("trim", operator.originalDescription(call)) + } + + @Test + fun `string uppercase swaps to lowercase`() { + val compiled = compile("fun f(s: String) = s.uppercase()") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString().startsWith("uppercase") } + val operator = StringMethodOperator() + + assertTrue(operator.matches(call)) + assertEquals("uppercase", operator.originalDescription(call)) + } + + @Test + fun `string method does not match non-string receivers`() { + val compiled = compile("class C { fun trim(): Int = 1 }\nfun f(c: C) = c.trim()") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "trim" } + val operator = StringMethodOperator() + + assertTrue(!operator.matches(call), "StringMethod must not match non-String receivers") + } + + // --- CollectionMethodOperator --- + + @Test + fun `collection filter swaps to filterNot`() { + val compiled = compile("fun f(xs: List) = xs.filter { it > 0 }") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "filter" } + val operator = CollectionMethodOperator() + + // matches() and the display name are verifiable in the isolated test compiler; + // full variant generation requires stdlib deserialization (verified end-to-end + // in mutflow-test-kmp). + assertTrue(operator.matches(call)) + assertEquals("filter", operator.originalDescription(call)) + } + + @Test + fun `collection isEmpty swaps to isNotEmpty`() { + val compiled = compile("fun f(xs: List) = xs.isEmpty()") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "isEmpty" } + val operator = CollectionMethodOperator() + + assertTrue(operator.matches(call)) + assertEquals("isEmpty", operator.originalDescription(call)) + } + + @Test + fun `collection min swaps to max`() { + val compiled = compile("fun f(xs: List) = xs.min()") + val call = compiled.module.collectCalls().first { it.symbol.owner.name.asString() == "min" } + val operator = CollectionMethodOperator() + + assertTrue(operator.matches(call)) + assertEquals("min", operator.originalDescription(call)) + } + + @Test + fun `collection method does not match non-collection receivers`() { + val compiled = compile("fun f(s: String): Int = s.length") + val operator = CollectionMethodOperator() + // No collection method calls present; a non-collection call should not match. + val calls = compiled.module.collectCalls().filter { it.symbol.owner.name.asString() == "length" } + assertTrue(calls.none { operator.matches(it) }, "CollectionMethod must not match non-collection calls") + } + + // --- ReferenceEqualityOperator --- + + @Test + fun `reference equality swaps === to !==`() { + val compiled = compile("fun f(a: Any, b: Any): Boolean = a === b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.EQEQEQ } + val operator = ReferenceEqualityOperator() + + assertTrue(operator.matches(call)) + assertEquals("===", operator.originalDescription(call)) + assertEquals(listOf("!=="), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `reference equality swaps !== to ===`() { + val compiled = compile("fun f(a: Any, b: Any): Boolean = a !== b") + val call = compiled.module.collectCalls().first { it.origin == IrStatementOrigin.EXCLEQEQ } + val operator = ReferenceEqualityOperator() + + assertTrue(operator.matches(call)) + assertEquals("!==", operator.originalDescription(call)) + assertEquals(listOf("==="), operator.variants(call, compiled.contextFor("f")).map { it.description }) + } + + // --- ElvisOperator --- + + @Test + fun `elvis replaces with subject and fallback`() { + val compiled = compile("fun f(a: String?): String = a ?: \"d\"") + val elvisWhen = compiled.module.collectWhens().firstOrNull { + it.origin?.debugName == "FOLDED_ELVIS" + } ?: error("no FOLDED_ELVIS when found") + val operator = ElvisOperator() + + assertTrue(operator.matches(elvisWhen)) + assertEquals("?:", operator.originalDescription(elvisWhen)) + assertEquals(listOf("b", "a"), operator.variants(elvisWhen, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `elvis does not match non-elvis whens`() { + val compiled = compile("fun f(a: Boolean): Int = if (a) 1 else 2") + val operator = ElvisOperator() + val whens = compiled.module.collectWhens() + assertTrue(whens.none { operator.matches(it) }, "Elvis must not match an if/when") + } + + // --- SafeCallOperator --- + + @Test + fun `safe call replaces with non-null access`() { + val compiled = compile("fun f(a: String?): Int? = a?.length") + val safeWhen = compiled.module.collectWhens().firstOrNull { + it.origin?.debugName == "FOLDED_SAFE_CALL" + } ?: error("no FOLDED_SAFE_CALL when found") + val operator = SafeCallOperator() + + assertTrue(operator.matches(safeWhen)) + assertEquals("?.", operator.originalDescription(safeWhen)) + assertEquals(listOf("a!!.b"), operator.variants(safeWhen, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `safe call does not match non-safe-call whens`() { + val compiled = compile("fun f(a: Boolean): Int = if (a) 1 else 2") + val operator = SafeCallOperator() + val whens = compiled.module.collectWhens() + assertTrue(whens.none { operator.matches(it) }, "SafeCall must not match an if/when") + } + + // --- EmptyCollectionReturnOperator --- + + @Test + fun `empty collection return replaces list with emptyList`() { + val compiled = compile("fun f(): List { return listOf(1, 2, 3) }") + val ret = compiled.module.collectReturns().first() + val operator = EmptyCollectionReturnOperator() + + assertTrue(operator.matches(ret)) + assertEquals(listOf("emptyList"), operator.variants(ret, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `empty collection return does not match non-collection returns`() { + val compiled = compile("fun f(): Int = 42") + val ret = compiled.module.collectReturns().first() + val operator = EmptyCollectionReturnOperator() + assertTrue(!operator.matches(ret), "EmptyCollectionReturn must not match a numeric return") + } + + // --- AssignConstOperator --- + + @Test + fun `assign const replaces numeric assignment with zero`() { + val compiled = compile("fun f(x: Int) { var a = x; a = x }") + val operator = AssignConstOperator() + // Use a variable assignment node. + val setValues = compiled.module.collectSetValues() + val assignment = setValues.last() // the `a = x` one + val targetType = assignment.symbol.owner.type + val value = assignment.value + + assertTrue(operator.matches(targetType, value)) + assertEquals(listOf("0"), operator.variants(targetType, value, compiled.contextFor("f")).map { it.description }) + } + + @Test + fun `assign const skips constant assignments`() { + val compiled = compile("fun f() { var a = 0; a = 0 }") + val setValues = compiled.module.collectSetValues() + val operator = AssignConstOperator() + // `a = 0` is an IrSetValue with a constant value. + val assignment = setValues.last() + assertTrue(!operator.matches(assignment.symbol.owner.type, assignment.value), + "AssignConst must skip constant assignments") + } + + // --- Materialized-expression regression tests --- + // + // The tests above only assert on `matches()`/`description`. These additionally call + // `createExpression()` and inspect the resulting IR node, catching bugs in the built + // expression itself (e.g. missing type arguments) that description-only assertions miss. + + @Test + fun `elvis 'a' variant produces a fully-typed checkNotNull call`() { + // Regression test: the "a" variant used to build `irCall(checkNotNullSymbol)` + // without setting typeArguments[0] or the call's result type, leaving an + // unbound type parameter on the materialized call. + val compiled = compile("fun f(a: String?): String = a ?: \"d\"") + val elvisWhen = compiled.module.collectWhens().first { it.origin?.debugName == "FOLDED_ELVIS" } + val operator = ElvisOperator() + + val aVariant = operator.variants(elvisWhen, compiled.contextFor("f")).first { it.description == "a" } + val expression = aVariant.createExpression() as IrCall + + assertTrue(expression.symbol.isBound, "checkNotNull call symbol must be bound") + val typeArgument = expression.typeArguments.getOrNull(0) + assertTrue(typeArgument != null, "checkNotNull call must have its type argument set") + assertEquals(typeArgument, expression.type, "call type must match the substituted type argument") + } + + @Test + fun `literal reassignment of a local var in a mutation target compiles without IR corruption`() { + // Regression test for a bug in MutflowIrTransformer.transformAssignmentValue: + // its fallback paths (no matching assignment operator, i.e. the assigned value + // is already a literal constant - AssignConstOperator explicitly skips those) + // returned the enclosing IrSetValue/IrSetField node itself instead of the + // assigned value, producing a self-referential `x = x` IR cycle and a + // StackOverflowError during compilation. + // + // Uses a local var, not a property: property assignment from within the class + // lowers to a call to the synthetic setter (`(0)`, an IrCall), not an + // IrSetField, so it never reaches transformAssignmentValue and wouldn't have + // exercised the bug. A local var reassignment lowers to a genuine IrSetValue. + // + // This runs the *real* compiler plugin end-to-end (not just IR capture) so the + // bug is caught the way it actually manifested: as a compilation crash. + val source = """ + import io.github.anschnapp.mutflow.MutationTarget + + @MutationTarget + class Counter { + fun reset(): Int { + var count = 1 + count = 0 + return count + } + } + """.trimIndent() + + IrTestCompiler.compile( + source, + extraPlugins = listOf(IrTestCompiler.createRealPluginDir().path), + extraClasspath = listOf( + IrTestCompiler.findProjectClasspathEntry("mutflow-annotations"), + IrTestCompiler.findProjectClasspathEntry("mutflow-core") + ) + ) + } +} diff --git a/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/TestIrCaptureRegistrar.kt b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/TestIrCaptureRegistrar.kt new file mode 100644 index 0000000..0ef820e --- /dev/null +++ b/mutflow-compiler-plugin/src/test/kotlin/io/github/anschnapp/mutflow/compiler/TestIrCaptureRegistrar.kt @@ -0,0 +1,33 @@ +package io.github.anschnapp.mutflow.compiler + +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment + +/** + * Test-only compiler plugin registrar that captures the compiled [IrModuleFragment] + * and [IrPluginContext] into [IrTestCompiler] so operator tests can walk the IR. + * + * Loaded by the in-process [org.jetbrains.kotlin.cli.jvm.K2JVMCompiler] through the + * `-Xplugin` mechanism: [IrTestCompiler] writes a ServiceLoader registration for + * this class into a temp plugin classpath before each compilation. The class itself + * resolves through the parent (test) classloader. + */ +@OptIn(ExperimentalCompilerApi::class) +class TestIrCaptureRegistrar : CompilerPluginRegistrar() { + + override val pluginId: String = "mutflow-ir-test-capture" + + override val supportsK2: Boolean = true + + override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + IrGenerationExtension.Companion.registerExtension(object : IrGenerationExtension { + override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { + IrTestCompiler.capture(moduleFragment, pluginContext) + } + }) + } +} diff --git a/mutflow-core/build.gradle.kts b/mutflow-core/build.gradle.kts index d12ea23..00ffa82 100644 --- a/mutflow-core/build.gradle.kts +++ b/mutflow-core/build.gradle.kts @@ -1,45 +1,62 @@ -plugins { - kotlin("jvm") - id("com.vanniktech.maven.publish") -} - -dependencies { - api(project(":mutflow-annotations")) - testImplementation(kotlin("test")) -} - -mavenPublishing { - publishToMavenCentral() - - // Only sign when credentials are available (CI environment) - if (project.hasProperty("signingInMemoryKey") || System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKey") != null) { - signAllPublications() - } - - pom { - name.set("mutflow-core") - description.set("Core registry for Mutflow - Lightweight mutation testing for Kotlin") - url.set("https://github.com/anschnapp/mutflow") - - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - - developers { - developer { - id.set("anschnapp") - name.set("Andreas Schnapp") - url.set("https://github.com/anschnapp") - } - } - - scm { - url.set("https://github.com/anschnapp/mutflow") - connection.set("scm:git:git://github.com/anschnapp/mutflow.git") - developerConnection.set("scm:git:ssh://git@github.com/anschnapp/mutflow.git") - } - } -} +plugins { + kotlin("multiplatform") + id("com.vanniktech.maven.publish") +} + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) +kotlin { + jvm() + js { + nodejs() + } + wasmJs { + nodejs() + } + linuxX64() + macosArm64() + + sourceSets { + commonMain.dependencies { + api(project(":mutflow-annotations")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +mavenPublishing { + publishToMavenCentral() + + // Only sign when credentials are available (CI environment) + if (project.hasProperty("signingInMemoryKey") || System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKey") != null) { + signAllPublications() + } + + pom { + name.set("mutflow-core") + description.set("Core registry for Mutflow - Lightweight mutation testing for Kotlin") + url.set("https://github.com/anschnapp/mutflow") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set("anschnapp") + name.set("Andreas Schnapp") + url.set("https://github.com/anschnapp") + } + } + + scm { + url.set("https://github.com/anschnapp/mutflow") + connection.set("scm:git:git://github.com/anschnapp/mutflow.git") + developerConnection.set("scm:git:ssh://git@github.com/anschnapp/mutflow.git") + } + } +} diff --git a/mutflow-core/src/commonMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.kt b/mutflow-core/src/commonMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.kt new file mode 100644 index 0000000..9c66dad --- /dev/null +++ b/mutflow-core/src/commonMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.kt @@ -0,0 +1,39 @@ +package io.github.anschnapp.mutflow + +/** + * Platform-specific thread-safe collection factories used by [MutationRegistry]. + * + * - JVM: backed by `java.util.concurrent` (real thread safety for parallel tests). + * - JS: single-threaded, plain collections are sufficient. + * - Native: plain collections; mutual exclusion is provided by the + * `synchronized(lock)` in [MutationRegistry.withSession], which guards all + * `check()` calls during a session. + */ +internal expect fun newConcurrentSet(): MutableSet + +internal expect fun newSynchronizedList(): MutableList + +/** + * A thread-safe mutable map, used by the runtime for thread→session routing. + * - JVM: backed by `java.util.concurrent.ConcurrentHashMap`. + * - JS / WASM / Native: single-threaded, plain map is sufficient. + */ +expect fun newConcurrentMap(): MutableMap + +/** + * Returns an identifier for the current thread, used to route parameterless + * `underTest()` calls to the right session. + * - JVM: `Thread.currentThread().id`. + * - JS / WASM / Native: a constant (single-threaded). + */ +expect fun currentThreadId(): Long + +/** + * Runs [block] while holding [lock], providing mutual exclusion for mutation + * sessions. `synchronized` is JVM-only in Kotlin, so this is expect/actual. + * + * - JVM: real `synchronized` (parallel test classes). + * - JS: single-threaded, no lock needed. + * - Native: no-op for now; tests run sequentially. TODO(Phase 3): real mutex. + */ +internal expect fun withSessionLock(lock: Any, block: () -> T): T diff --git a/mutflow-core/src/main/kotlin/io/github/anschnapp/mutflow/MutationRegistry.kt b/mutflow-core/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutationRegistry.kt similarity index 87% rename from mutflow-core/src/main/kotlin/io/github/anschnapp/mutflow/MutationRegistry.kt rename to mutflow-core/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutationRegistry.kt index f2af521..dd27e9f 100644 --- a/mutflow-core/src/main/kotlin/io/github/anschnapp/mutflow/MutationRegistry.kt +++ b/mutflow-core/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutationRegistry.kt @@ -1,7 +1,9 @@ package io.github.anschnapp.mutflow -import java.util.Collections -import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.Volatile +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource /** * Central registry for mutation point tracking and activation. @@ -13,6 +15,10 @@ import java.util.concurrent.ConcurrentHashMap * Thread safety: Use [withSession] to ensure mutual exclusion when multiple * test classes may run in parallel. The lock is held for the duration of the * block execution, so only one mutation session is active at a time. + * + * Multiplatform: lives in `commonMain`; the only platform-specific pieces are + * the concurrent collection factories ([newConcurrentSet], [newSynchronizedList]) + * and the monotonic clock (via [TimeSource.Monotonic], which is common). */ object MutationRegistry { @@ -33,8 +39,8 @@ object MutationRegistry { */ fun checkTimeout() { val session = currentSession ?: return - val deadline = session.deadlineNanos - if (deadline > 0 && System.nanoTime() > deadline) { + val deadline = session.deadline + if (deadline != null && deadline.hasPassedNow()) { throw MutationTimedOutException( "Mutation timed out. This mutation likely causes an infinite loop.\n" + "Add a // mutflow:ignore comment on the affected line to skip it." @@ -128,16 +134,16 @@ object MutationRegistry { timeoutMs: Long = 0, block: () -> T ): Pair { - synchronized(lock) { + return withSessionLock(lock) { check(currentSession == null) { "Session already active" } - val deadlineNanos = if (activeMutation != null && timeoutMs > 0) { - System.nanoTime() + timeoutMs * 1_000_000 - } else 0L - currentSession = Session(activeMutation, deadlineNanos = deadlineNanos) + val deadline = if (activeMutation != null && timeoutMs > 0) { + TimeSource.Monotonic.markNow() + timeoutMs.milliseconds + } else null + currentSession = Session(activeMutation, deadline) try { val result = block() val session = currentSession!! - return result to SessionResult( + result to SessionResult( mutationPointCount = session.discoveredPoints.size, discoveredPoints = session.discoveredPoints.toList() ) @@ -161,9 +167,9 @@ object MutationRegistry { private class Session( val activeMutation: ActiveMutation?, - val deadlineNanos: Long = 0, - val discoveredPoints: MutableList = Collections.synchronizedList(mutableListOf()), - val seenPointIds: MutableSet = ConcurrentHashMap.newKeySet() + val deadline: TimeMark? = null, + val discoveredPoints: MutableList = newSynchronizedList(), + val seenPointIds: MutableSet = newConcurrentSet() ) } diff --git a/mutflow-core/src/test/kotlin/io/github/anschnapp/mutflow/MutationRegistryTest.kt b/mutflow-core/src/commonTest/kotlin/io/github/anschnapp/mutflow/MutationRegistryTest.kt similarity index 100% rename from mutflow-core/src/test/kotlin/io/github/anschnapp/mutflow/MutationRegistryTest.kt rename to mutflow-core/src/commonTest/kotlin/io/github/anschnapp/mutflow/MutationRegistryTest.kt diff --git a/mutflow-core/src/jsMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.js.kt b/mutflow-core/src/jsMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.js.kt new file mode 100644 index 0000000..0a0205b --- /dev/null +++ b/mutflow-core/src/jsMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.js.kt @@ -0,0 +1,12 @@ +package io.github.anschnapp.mutflow + +// JS is single-threaded: plain collections are sufficient. +internal actual fun newConcurrentSet(): MutableSet = mutableSetOf() + +internal actual fun newSynchronizedList(): MutableList = mutableListOf() + +actual fun newConcurrentMap(): MutableMap = mutableMapOf() + +actual fun currentThreadId(): Long = 0L + +internal actual fun withSessionLock(lock: Any, block: () -> T): T = block() diff --git a/mutflow-core/src/jvmMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.jvm.kt b/mutflow-core/src/jvmMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.jvm.kt new file mode 100644 index 0000000..fb91917 --- /dev/null +++ b/mutflow-core/src/jvmMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.jvm.kt @@ -0,0 +1,14 @@ +package io.github.anschnapp.mutflow + +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +internal actual fun newConcurrentSet(): MutableSet = ConcurrentHashMap.newKeySet() + +internal actual fun newSynchronizedList(): MutableList = Collections.synchronizedList(mutableListOf()) + +actual fun newConcurrentMap(): MutableMap = ConcurrentHashMap() + +actual fun currentThreadId(): Long = Thread.currentThread().id + +internal actual fun withSessionLock(lock: Any, block: () -> T): T = synchronized(lock, block) diff --git a/mutflow-core/src/nativeMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.native.kt b/mutflow-core/src/nativeMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.native.kt new file mode 100644 index 0000000..e64d9b6 --- /dev/null +++ b/mutflow-core/src/nativeMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.native.kt @@ -0,0 +1,14 @@ +package io.github.anschnapp.mutflow + +// Mutual exclusion is provided by the lock in MutationRegistry.withSession, +// which guards all check() calls during a session. Plain collections are sufficient. +internal actual fun newConcurrentSet(): MutableSet = mutableSetOf() + +internal actual fun newSynchronizedList(): MutableList = mutableListOf() + +actual fun newConcurrentMap(): MutableMap = mutableMapOf() + +actual fun currentThreadId(): Long = 0L + +// TODO(Phase 3): real mutex for Native. Tests run sequentially for now. +internal actual fun withSessionLock(lock: Any, block: () -> T): T = block() diff --git a/mutflow-core/src/wasmJsMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.wasmJs.kt b/mutflow-core/src/wasmJsMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.wasmJs.kt new file mode 100644 index 0000000..bdb4206 --- /dev/null +++ b/mutflow-core/src/wasmJsMain/kotlin/io/github/anschnapp/mutflow/ConcurrentCollections.wasmJs.kt @@ -0,0 +1,12 @@ +package io.github.anschnapp.mutflow + +// WasmJs is single-threaded: plain collections are sufficient. +internal actual fun newConcurrentSet(): MutableSet = mutableSetOf() + +internal actual fun newSynchronizedList(): MutableList = mutableListOf() + +actual fun newConcurrentMap(): MutableMap = mutableMapOf() + +actual fun currentThreadId(): Long = 0L + +internal actual fun withSessionLock(lock: Any, block: () -> T): T = block() diff --git a/mutflow-gradle-plugin/src/main/kotlin/io/github/anschnapp/mutflow/gradle/MutflowGradlePlugin.kt b/mutflow-gradle-plugin/src/main/kotlin/io/github/anschnapp/mutflow/gradle/MutflowGradlePlugin.kt index 98a3f37..c1925bd 100644 --- a/mutflow-gradle-plugin/src/main/kotlin/io/github/anschnapp/mutflow/gradle/MutflowGradlePlugin.kt +++ b/mutflow-gradle-plugin/src/main/kotlin/io/github/anschnapp/mutflow/gradle/MutflowGradlePlugin.kt @@ -77,6 +77,56 @@ class MutflowGradlePlugin : Plugin, KotlinCompilerPluginSupportPlugin { } debug(" configuration complete") } + + // Kotlin Multiplatform: apply the compiler plugin to every compilation and + // wire the runtime into commonMain/commonTest. Production code stays clean + // because the plugin only injects mutations into @MutationTarget classes + // (or classes matching the configured target patterns). This mirrors how the + // KMP sample project (mutflow-test-kmp) is wired up. + target.plugins.withId("org.jetbrains.kotlin.multiplatform") { + debug(" kotlin.multiplatform plugin detected, configuring...") + target.afterEvaluate { + if (extension.enabled.get()) { + addKmpDependencies(target) + } else { + // Keep code compiling even when mutation testing is disabled. + addKmpDependencies(target) + } + } + debug(" KMP configuration complete") + } + } + + /** + * Adds the mutflow runtime dependencies to the KMP common source sets. + * + * - `commonMain` gets `mutflow-annotations` (for `@MutationTarget`) and + * `mutflow-core` (for the `MutationRegistry.check()` calls the compiler + * plugin injects). + * - `commonTest` gets `mutflow-core` so tests can drive sessions. + * + * The compiler plugin itself is applied to every compilation via + * [isApplicable] / [applyToCompilation]; production artifacts remain clean + * because injection is gated on `@MutationTarget` / target patterns. + */ + private fun addKmpDependencies(project: Project) { + val kotlin = project.extensions.findByName("kotlin") + ?: return + val sourceSets = (kotlin as? org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension) + ?.sourceSets + ?: return + + sourceSets.matching { it.name == "commonMain" }.configureEach { ss -> + ss.dependencies { + implementation("$GROUP_ID:mutflow-annotations:$MUTFLOW_VERSION") + implementation("$GROUP_ID:mutflow-core:$MUTFLOW_VERSION") + } + } + sourceSets.matching { it.name == "commonTest" }.configureEach { ss -> + ss.dependencies { + implementation("$GROUP_ID:mutflow-core:$MUTFLOW_VERSION") + } + } } /** @@ -181,8 +231,12 @@ class MutflowGradlePlugin : Plugin, KotlinCompilerPluginSupportPlugin { val extension = project.extensions.findByType(MutflowExtension::class.java) val enabled = extension?.enabled?.get() ?: true val compilationName = kotlinCompilation.name - val isApplicable = enabled && compilationName == MUTATED_MAIN - debug("isApplicable(compilation='$compilationName', enabled=$enabled) -> $isApplicable") + // JVM: only the dedicated `mutatedMain` compilation is mutated (dual-compilation). + // KMP: apply to every compilation (main + test) — injection is gated on + // @MutationTarget / target patterns, so production artifacts stay clean. + val isKmp = project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") + val isApplicable = enabled && (if (isKmp) true else compilationName == MUTATED_MAIN) + debug("isApplicable(compilation='$compilationName', enabled=$enabled, kmp=$isKmp) -> $isApplicable") return isApplicable } diff --git a/mutflow-runtime/build.gradle.kts b/mutflow-runtime/build.gradle.kts index c267930..e3f8df3 100644 --- a/mutflow-runtime/build.gradle.kts +++ b/mutflow-runtime/build.gradle.kts @@ -1,11 +1,28 @@ plugins { - kotlin("jvm") + kotlin("multiplatform") id("com.vanniktech.maven.publish") } -dependencies { - api(project(":mutflow-core")) - testImplementation(kotlin("test")) +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) +kotlin { + jvm() + js { + nodejs() + } + wasmJs { + nodejs() + } + linuxX64() + macosArm64() + + sourceSets { + commonMain.dependencies { + api(project(":mutflow-core")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } } mavenPublishing { diff --git a/mutflow-runtime/src/main/kotlin/io/github/anschnapp/mutflow/MutFlow.kt b/mutflow-runtime/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutFlow.kt similarity index 95% rename from mutflow-runtime/src/main/kotlin/io/github/anschnapp/mutflow/MutFlow.kt rename to mutflow-runtime/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutFlow.kt index f32febb..9cdf417 100644 --- a/mutflow-runtime/src/main/kotlin/io/github/anschnapp/mutflow/MutFlow.kt +++ b/mutflow-runtime/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutFlow.kt @@ -1,8 +1,9 @@ package io.github.anschnapp.mutflow -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap import kotlin.random.Random +import kotlin.time.TimeSource +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Main entry point for mutation testing in tests. @@ -35,7 +36,7 @@ object MutFlow { // Maps thread ID to session ID, set in startRun, cleared in endRun. // Allows parameterless underTest() to find the right session when // multiple test classes run in parallel on different threads. - private val threadToSession = ConcurrentHashMap() + private val threadToSession = newConcurrentMap() // ==================== Session Management (for JUnit extension) ==================== @@ -61,7 +62,7 @@ object MutFlow { timeoutMs: Long = 60_000, verificationMode: VerificationMode = VerificationMode.STRICT ): SessionId { - val id = SessionId(UUID.randomUUID()) + val id = SessionId(newUuid()) val session = MutFlowSession( id = id, selection = selection, @@ -112,7 +113,7 @@ object MutFlow { fun startRun(sessionId: SessionId, run: Int, mutation: Mutation? = null) { val session = sessions[sessionId] ?: error("Session not found: $sessionId") - threadToSession[Thread.currentThread().id] = sessionId + threadToSession[currentThreadId()] = sessionId session.startRun(run, mutation) } @@ -122,7 +123,7 @@ object MutFlow { */ fun endRun(sessionId: SessionId) { sessions[sessionId]?.endRun() - threadToSession.remove(Thread.currentThread().id) + threadToSession.remove(currentThreadId()) } /** @@ -151,7 +152,7 @@ object MutFlow { * @throws MutationsExhaustedException if all mutations have been tested */ fun underTest(block: () -> T): T { - val sessionId = threadToSession[Thread.currentThread().id] + val sessionId = threadToSession[currentThreadId()] ?: error("No active MutFlow session on this thread. Use @MutFlowTest annotation or call underTest(run, selection, shuffle) directly.") val session = sessions[sessionId] @@ -326,7 +327,7 @@ object MutFlow { private fun legacyGetOrCreateGlobalSeed(): Long { if (legacyGlobalSeed == null) { - legacyGlobalSeed = System.currentTimeMillis() xor System.nanoTime() + legacyGlobalSeed = newSeed() println("[mutflow] Generated seed: $legacyGlobalSeed") } return legacyGlobalSeed!! @@ -371,6 +372,17 @@ object MutFlow { } } +/** + * Generates a random UUID using the multiplatform `kotlin.uuid.Uuid` API. + */ +@OptIn(ExperimentalUuidApi::class) +internal fun newUuid(): Uuid = Uuid.random() + +/** + * Generates a seed value from the monotonic clock, usable on every backend. + */ +internal fun newSeed(): Long = TimeSource.Monotonic.markNow().elapsedNow().inWholeNanoseconds + /** * Determines how mutations are selected. */ diff --git a/mutflow-runtime/src/main/kotlin/io/github/anschnapp/mutflow/MutFlowSession.kt b/mutflow-runtime/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutFlowSession.kt similarity index 99% rename from mutflow-runtime/src/main/kotlin/io/github/anschnapp/mutflow/MutFlowSession.kt rename to mutflow-runtime/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutFlowSession.kt index cc5030b..2d1c1eb 100644 --- a/mutflow-runtime/src/main/kotlin/io/github/anschnapp/mutflow/MutFlowSession.kt +++ b/mutflow-runtime/src/commonMain/kotlin/io/github/anschnapp/mutflow/MutFlowSession.kt @@ -1,12 +1,12 @@ package io.github.anschnapp.mutflow -import java.util.UUID import kotlin.random.Random +import kotlin.uuid.Uuid /** * Identifier for [MutFlowSession] instances. */ -data class SessionId(val value: UUID) +data class SessionId(val value: Uuid) /** * Holds all mutation testing state for a single test class execution. @@ -442,7 +442,7 @@ class MutFlowSession internal constructor( private fun getOrCreateSessionSeed(): Long { if (sessionSeed == null) { - sessionSeed = System.currentTimeMillis() xor System.nanoTime() + sessionSeed = newSeed() println("[mutflow] Session $id - Generated seed: $sessionSeed") } return sessionSeed!! diff --git a/mutflow-runtime/src/test/kotlin/io/github/anschnapp/mutflow/MutFlowTest.kt b/mutflow-runtime/src/commonTest/kotlin/io/github/anschnapp/mutflow/MutFlowTest.kt similarity index 100% rename from mutflow-runtime/src/test/kotlin/io/github/anschnapp/mutflow/MutFlowTest.kt rename to mutflow-runtime/src/commonTest/kotlin/io/github/anschnapp/mutflow/MutFlowTest.kt diff --git a/mutflow-test-kmp/build.gradle.kts b/mutflow-test-kmp/build.gradle.kts new file mode 100644 index 0000000..000f4a9 --- /dev/null +++ b/mutflow-test-kmp/build.gradle.kts @@ -0,0 +1,44 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask + +plugins { + kotlin("multiplatform") +} + +val compilerPluginJar = project(":mutflow-compiler-plugin").tasks.named("jar") + +@OptIn(ExperimentalWasmDsl::class) +kotlin { + jvm() + js { + nodejs() + } + wasmJs { + nodejs() + } + linuxX64() + macosArm64() + + sourceSets { + commonMain.dependencies { + implementation(project(":mutflow-annotations")) + implementation(project(":mutflow-core")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +// Apply the mutflow compiler plugin to every compilation (production + test). +// The plugin only injects mutations into @MutationTarget classes, so production +// code is unaffected unless explicitly annotated. +tasks.withType>().configureEach { + dependsOn(compilerPluginJar) + compilerOptions { + val pluginJarPath = compilerPluginJar.get().outputs.files.singleFile.absolutePath + freeCompilerArgs.add("-Xplugin=$pluginJarPath") + // Verify the injected IR is well-formed on every backend (PLAN.md Phase 2). + freeCompilerArgs.add("-Xverify-ir=error") + } +} diff --git a/mutflow-test-kmp/src/commonMain/kotlin/sample/Calculator.kt b/mutflow-test-kmp/src/commonMain/kotlin/sample/Calculator.kt new file mode 100644 index 0000000..ae1ad7b --- /dev/null +++ b/mutflow-test-kmp/src/commonMain/kotlin/sample/Calculator.kt @@ -0,0 +1,59 @@ +package sample + +import io.github.anschnapp.mutflow.MutationTarget + +/** + * Sample class under mutation testing, shared across all KMP targets. + * + * Exercises the core operator families: arithmetic, relational comparison, + * boolean logic, and control flow. + */ +@MutationTarget +class Calculator { + + fun add(a: Int, b: Int): Int = a + b + + fun isPositive(x: Int): Boolean = x > 0 + + fun isInRange(x: Int): Boolean = x > 0 && x < 100 + + fun max(a: Int, b: Int): Int = if (a > b) a else b + + fun startsWithA(s: String): Boolean = s.endsWith("A") + + fun normalized(s: String): String = s.trim() + + fun hasEven(xs: List): Boolean = xs.filter { it % 2 == 0 }.isNotEmpty() + + fun sameRef(a: Any, b: Any): Boolean = a === b + + fun notSameRef(a: Any, b: Any): Boolean = a !== b + + fun greet(name: String?): String = name ?: "guest" + + fun lengthOf(s: String?): Int? = s?.length + + fun emptyListReturn(): List { + return listOf(1, 2, 3) + } + + fun doubleThenSet(x: Int): Int { + var result = 0 + result = x * 2 + return result + } + + // --- Experimental operators --- + + fun switchOp(x: Int): Int = when (x) { + 1 -> 10 + 2 -> 20 + else -> 0 + } + + fun combine(a: Int, b: Int): Int = sum(a, b) + + private fun sum(a: Int, b: Int): Int = a + b + + fun matchesRegex(s: String): Boolean = Regex("^a").containsMatchIn(s) +} diff --git a/mutflow-test-kmp/src/commonTest/kotlin/sample/CalculatorTest.kt b/mutflow-test-kmp/src/commonTest/kotlin/sample/CalculatorTest.kt new file mode 100644 index 0000000..2de7163 --- /dev/null +++ b/mutflow-test-kmp/src/commonTest/kotlin/sample/CalculatorTest.kt @@ -0,0 +1,87 @@ +package sample + +import io.github.anschnapp.mutflow.ActiveMutation +import io.github.anschnapp.mutflow.MutationRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies the compiler-injected mutation points work on every KMP target. + * + * The baseline test proves the `MutationRegistry.check(...)` calls are injected + * and executed; the active-mutation test proves a selected variant actually + * changes behavior. + */ +class CalculatorTest { + + @Test + fun baselineDiscoversMutationPoints() { + val (_, session) = MutationRegistry.withSession { + val calc = Calculator() + calc.add(1, 2) + calc.isPositive(5) + calc.isInRange(50) + calc.max(3, 1) + calc.startsWithA("x") + calc.normalized(" x ") + calc.hasEven(listOf(1, 2, 3)) + calc.sameRef("a", "a") + calc.notSameRef("a", "b") + calc.greet(null) + calc.lengthOf("abc") + calc.emptyListReturn() + calc.doubleThenSet(21) + calc.switchOp(2) + calc.combine(3, 4) + calc.matchesRegex("abc") + } + assertTrue( + session.mutationPointCount > 0, + "expected mutation points, got ${session.mutationPointCount}" + ) + // The string/collection operators must produce mutation points in a real + // compilation (endsWith→startsWith, trim→"", filter→filterNot, isEmpty→isNotEmpty). + val operators = session.discoveredPoints.map { it.originalOperator }.toSet() + assertTrue(operators.any { it.startsWith("endsWith") }, "expected endsWith mutation, got: $operators") + assertTrue(operators.contains("trim"), "expected trim mutation, got: $operators") + assertTrue(operators.contains("filter"), "expected filter mutation, got: $operators") + assertTrue(operators.any { it.startsWith("isNotEmpty") }, "expected isNotEmpty mutation, got: $operators") + // The reference-equality / elvis / safe-call operators must also fire. + assertTrue(operators.contains("==="), "expected === mutation, got: $operators") + assertTrue(operators.contains("!=="), "expected !== mutation, got: $operators") + assertTrue(operators.contains("?:"), "expected ?: (elvis) mutation, got: $operators") + assertTrue(operators.contains("?."), "expected ?. (safe-call) mutation, got: $operators") + // The empty-collection return fires on the listOf return (its point is labeled + // "return ...", shared with ObjectReturn; the emptyList variant is asserted in + // the compiler-plugin unit test). Confirm a return point exists for the line. + assertTrue(operators.contains("return ..."), "expected a return mutation, got: $operators") + // The assign-const operator fires on the `result = x * 2` assignment. + assertTrue(operators.contains("="), "expected assign-const (=) mutation, got: $operators") + // The experimental operators fire on the real compilation across all backends. + assertTrue(operators.contains("when"), "expected switch (when) mutation, got: $operators") + assertTrue(operators.contains("sum"), "expected argument-propagation (sum) mutation, got: $operators") + assertTrue( + operators.any { it.startsWith("Regex(") }, + "expected Regex pattern mutation, got: $operators" + ) + } + + @Test + fun activeMutationChangesBehavior() { + // Discover the points first. + val (_, baseline) = MutationRegistry.withSession { + Calculator().add(1, 2) + } + val point = baseline.discoveredPoints.first() + + // Activate variant 0 of the first point and re-run. + val (result, _) = MutationRegistry.withSession(ActiveMutation(point.pointId, 0)) { + Calculator().add(1, 2) + } + + // add(1,2) is 3 normally; the first mutation point on this line is the + // arithmetic swap (+ → -), so the active mutant must differ. + assertEquals(-1, result, "active mutation should change add(1,2) from 3 to -1") + } +} diff --git a/mutflow-test-kmp/src/commonTest/kotlin/sample/PlatformInspectorTest.kt b/mutflow-test-kmp/src/commonTest/kotlin/sample/PlatformInspectorTest.kt new file mode 100644 index 0000000..6e3264c --- /dev/null +++ b/mutflow-test-kmp/src/commonTest/kotlin/sample/PlatformInspectorTest.kt @@ -0,0 +1,145 @@ +package sample + +import io.github.anschnapp.mutflow.ActiveMutation +import io.github.anschnapp.mutflow.DiscoveredPoint +import io.github.anschnapp.mutflow.MutationRegistry +import kotlin.test.Test + +/** + * Multiplatform binary inspector. + * + * Runs the same test battery against the MUTATED Calculator on every KMP target + * (JVM, JS, WASM, Native), discovers every mutation point, and for each point × + * variant records whether the mutant was killed (any input's result differed from + * baseline) or survived. Results are serialized to JSON and written to + * `build/inspect-results/.json` via the platform-specific [writeResultsFile]. + * + * A separate script (`tools/mutflow-inspect/inspect-all.sh`) runs all targets and + * aggregates the JSON files into one HTML dashboard. + */ +class PlatformInspectorTest { + + private val calc = Calculator() + + // Battery: method name -> list of input suppliers. Each input is run under the + // baseline and under every mutant; a differing result kills the mutant. + private val battery: Map Any?>> = mapOf( + "add" to listOf({ calc.add(5, 3) }, { calc.add(0, 0) }, { calc.add(-2, 7) }), + "isPositive" to listOf({ calc.isPositive(5) }, { calc.isPositive(0) }, { calc.isPositive(-3) }), + "isInRange" to listOf({ calc.isInRange(50) }, { calc.isInRange(0) }, { calc.isInRange(200) }), + "max" to listOf({ calc.max(3, 1) }, { calc.max(1, 3) }, { calc.max(4, 4) }), + "startsWithA" to listOf({ calc.startsWithA("A") }, { calc.startsWithA("B") }, { calc.startsWithA("") }), + "normalized" to listOf({ calc.normalized(" x ") }, { calc.normalized("x") }, { calc.normalized("") }), + "sameRef" to listOf({ calc.sameRef("a", "a") }, { calc.sameRef("a", "b") }), + "notSameRef" to listOf({ calc.notSameRef("a", "a") }, { calc.notSameRef("a", "b") }), + "greet" to listOf({ calc.greet(null) }, { calc.greet("bob") }), + "lengthOf" to listOf({ calc.lengthOf("abc") }, { calc.lengthOf(null) }), + "emptyListReturn" to listOf({ calc.emptyListReturn() }), + "doubleThenSet" to listOf({ calc.doubleThenSet(21) }, { calc.doubleThenSet(0) }), + "switchOp" to listOf({ calc.switchOp(1) }, { calc.switchOp(2) }, { calc.switchOp(9) }), + "combine" to listOf({ calc.combine(3, 4) }, { calc.combine(0, 0) }), + "matchesRegex" to listOf({ calc.matchesRegex("abc") }, { calc.matchesRegex("xabc") }, { calc.matchesRegex("xyz") }) + ) + + @Test + fun inspectAllPlatforms() { + // Baseline: discover points + record expected results per input. + val (expected, baseline) = MutationRegistry.withSession>>(null) { runBattery() } + val points = baseline.discoveredPoints + + // For each point × variant, run the battery and classify killed/survived. + val variants = mutableListOf() + for (p in points) { + for (v in 0 until p.variantCount) { + val mutant = MutationRegistry.withSession>>( + ActiveMutation(p.pointId, v) + ) { runBattery() }.first + val killed = differs(expected, mutant) + variants.add( + VariantResult( + pointId = p.pointId, + variant = v, + operator = p.originalOperator, + location = p.sourceLocation, + killed = killed, + detail = detailLines(expected, mutant) + ) + ) + } + } + + writeResultsFile(currentPlatform(), buildJson(currentPlatform(), variants)) + } + + private fun runBattery(): Map> { + val results = mutableMapOf>() + for ((name, inputs) in battery) { + results[name] = inputs.map { input -> + try { + input() + } catch (t: Throwable) { + "CRASH:${t::class.simpleName}" + } + } + } + return results + } + + private fun differs(a: Map>, b: Map>): Boolean { + for ((k, la) in a) { + val lb = b[k] ?: return true + if (la.size != lb.size) return true + for (i in la.indices) if (la[i] != lb[i]) return true + } + return false + } + + /** Returns lines like "add[0]: 8 -> 2" for each input whose result changed. */ + private fun detailLines(a: Map>, b: Map>): List { + val lines = mutableListOf() + for ((k, la) in a) { + val lb = b[k] ?: continue + if (la.size != lb.size) continue + for (i in la.indices) { + if (la[i] != lb[i]) lines.add("$k[$i]: ${la[i]} -> ${lb[i]}") + } + } + return lines + } + + private fun buildJson(platform: String, variants: List): String { + val sb = StringBuilder() + sb.append("{\"platform\":").append(jsonStr(platform)).append(",\"variants\":[") + variants.forEachIndexed { i, v -> + if (i > 0) sb.append(",") + sb.append("{") + .append("\"pointId\":").append(jsonStr(v.pointId)).append(",") + .append("\"variant\":").append(v.variant).append(",") + .append("\"operator\":").append(jsonStr(v.operator)).append(",") + .append("\"location\":").append(jsonStr(v.location)).append(",") + .append("\"killed\":").append(v.killed).append(",") + .append("\"detail\":").append(jsonStr(v.detail.joinToString(" | "))) + .append("}") + } + sb.append("]}") + return sb.toString() + } + + private fun jsonStr(s: String): String = + "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"" + + data class VariantResult( + val pointId: String, + val variant: Int, + val operator: String, + val location: String, + val killed: Boolean, + val detail: List + ) +} + +/** Platform name (e.g. "jvm", "js", "wasmJs", "linuxX64"). */ +internal expect fun currentPlatform(): String + +/** Writes the inspector JSON to `build/inspect-results/.json`. */ +internal expect fun writeResultsFile(platform: String, json: String) diff --git a/mutflow-test-kmp/src/jsTest/kotlin/sample/PlatformInspectorActual.js.kt b/mutflow-test-kmp/src/jsTest/kotlin/sample/PlatformInspectorActual.js.kt new file mode 100644 index 0000000..b60b287 --- /dev/null +++ b/mutflow-test-kmp/src/jsTest/kotlin/sample/PlatformInspectorActual.js.kt @@ -0,0 +1,22 @@ +package sample + +import kotlin.js.Json +import kotlin.js.json + +internal actual fun currentPlatform(): String = "js" + +// Node.js `fs` module, accessed via @JsModule so the nodejs target can write the +// inspector JSON to inspect-results/.json in the test working directory +// (the JS package dir). inspect-all.sh globs the repo for these files. +@JsModule("fs") +@JsNonModule +private external object fs { + fun mkdirSync(path: String, options: Any? = definedExternally) + fun writeFileSync(path: String, data: String, options: Any? = definedExternally) +} + +internal actual fun writeResultsFile(platform: String, json: String) { + val dir = "inspect-results" + fs.mkdirSync(dir, json("recursive" to true)) + fs.writeFileSync("$dir/$platform.json", json) +} diff --git a/mutflow-test-kmp/src/jvmTest/kotlin/sample/PlatformInspectorActual.jvm.kt b/mutflow-test-kmp/src/jvmTest/kotlin/sample/PlatformInspectorActual.jvm.kt new file mode 100644 index 0000000..fd879ac --- /dev/null +++ b/mutflow-test-kmp/src/jvmTest/kotlin/sample/PlatformInspectorActual.jvm.kt @@ -0,0 +1,10 @@ +package sample + +import java.io.File + +internal actual fun currentPlatform(): String = "jvm" + +internal actual fun writeResultsFile(platform: String, json: String) { + val dir = File("build/inspect-results").apply { mkdirs() } + File(dir, "$platform.json").writeText(json) +} diff --git a/mutflow-test-kmp/src/linuxX64Test/kotlin/sample/PlatformInspectorActual.linuxX64.kt b/mutflow-test-kmp/src/linuxX64Test/kotlin/sample/PlatformInspectorActual.linuxX64.kt new file mode 100644 index 0000000..3c01d9a --- /dev/null +++ b/mutflow-test-kmp/src/linuxX64Test/kotlin/sample/PlatformInspectorActual.linuxX64.kt @@ -0,0 +1,3 @@ +package sample + +internal actual fun currentPlatform(): String = "linuxX64" diff --git a/mutflow-test-kmp/src/macosArm64Test/kotlin/sample/PlatformInspectorActual.macosArm64.kt b/mutflow-test-kmp/src/macosArm64Test/kotlin/sample/PlatformInspectorActual.macosArm64.kt new file mode 100644 index 0000000..b837925 --- /dev/null +++ b/mutflow-test-kmp/src/macosArm64Test/kotlin/sample/PlatformInspectorActual.macosArm64.kt @@ -0,0 +1,3 @@ +package sample + +internal actual fun currentPlatform(): String = "macosArm64" diff --git a/mutflow-test-kmp/src/nativeTest/kotlin/sample/PlatformInspectorActual.native.kt b/mutflow-test-kmp/src/nativeTest/kotlin/sample/PlatformInspectorActual.native.kt new file mode 100644 index 0000000..7ff96ee --- /dev/null +++ b/mutflow-test-kmp/src/nativeTest/kotlin/sample/PlatformInspectorActual.native.kt @@ -0,0 +1,40 @@ +package sample + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import platform.posix.mkdir +import platform.posix.open +import platform.posix.O_CREAT +import platform.posix.O_WRONLY +import platform.posix.O_TRUNC +import platform.posix.write +import platform.posix.close +import platform.posix.S_IRUSR +import platform.posix.S_IWUSR +import platform.posix.S_IXUSR +import platform.posix.EEXIST +import platform.posix.errno +import platform.posix.strerror + +// Shared across every Kotlin/Native leaf target (linuxX64, macosArm64, ...) via the +// default hierarchy template's "native" intermediate source set. Per-target platform +// naming lives in each leaf target's own PlatformInspectorActual..kt. +@OptIn(ExperimentalForeignApi::class) +internal actual fun writeResultsFile(platform: String, json: String) { + // mode_t is a different width per platform (e.g. UShort on Darwin, UInt on + // Linux); `.convert()` adapts to whatever each platform's binding expects. + if (mkdir("inspect-results", (S_IRUSR or S_IWUSR or S_IXUSR).convert()) != 0 && errno != EEXIST) { + error("mkdir(inspect-results) failed: ${strerror(errno)?.toKString()}") + } + val path = "inspect-results/$platform.json" + val fd = open(path, O_CREAT or O_WRONLY or O_TRUNC, S_IRUSR or S_IWUSR) + check(fd >= 0) { "open($path) failed: ${strerror(errno)?.toKString()}" } + val bytes = json.encodeToByteArray() + bytes.usePinned { pinned -> + write(fd, pinned.addressOf(0), bytes.size.toULong()) + } + close(fd) +} diff --git a/mutflow-test-kmp/src/wasmJsTest/kotlin/sample/PlatformInspectorActual.wasm.kt b/mutflow-test-kmp/src/wasmJsTest/kotlin/sample/PlatformInspectorActual.wasm.kt new file mode 100644 index 0000000..16af594 --- /dev/null +++ b/mutflow-test-kmp/src/wasmJsTest/kotlin/sample/PlatformInspectorActual.wasm.kt @@ -0,0 +1,23 @@ +package sample + +internal actual fun currentPlatform(): String = "wasmJs" + +// Node.js `fs` module, accessed via @JsModule so the wasmJs (nodejs) target can +// write the inspector JSON to inspect-results/.json in the test working +// directory (the WASM package dir). inspect-all.sh globs the repo for these files. +// Kotlin/Wasm JS interop only allows external/primitive/string/function params, +// so we check existsSync first rather than calling mkdirSync unconditionally and +// swallowing its EEXIST throw — that would also hide genuine mkdir failures. +@JsModule("fs") +private external object fs { + fun existsSync(path: String): Boolean + fun mkdirSync(path: String) + fun writeFileSync(path: String, data: String) +} + +internal actual fun writeResultsFile(platform: String, json: String) { + if (!fs.existsSync("inspect-results")) { + fs.mkdirSync("inspect-results") + } + fs.writeFileSync("inspect-results/$platform.json", json) +} diff --git a/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt b/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt index f31f06f..a46a78e 100644 --- a/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt +++ b/mutflow-test-sample/src/test/kotlin/sample/NullSafetyTargetTest.kt @@ -6,16 +6,19 @@ import io.github.anschnapp.mutflow.Selection import io.github.anschnapp.mutflow.Shuffle import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertTrue +import kotlin.test.assertEquals /** - * Regression test: Kotlin null-safety operators (`?:`, `?.`, `!!`) must produce - * NO mutation points. + * Regression test for Kotlin null-safety operators (`?:`, `?.`, `!!`). * * 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. + * Two things must hold: + * 1. EqualitySwapOperator must NOT produce spurious `== ↔ !=` points on the + * synthetic null checks — the developer never wrote an equality operator, + * so those points would be misleading (see EqualitySwapOperator). + * 2. The dedicated elvis (`?:`) and safe-call (`?.`) mutators DO fire on these + * constructs, producing `?:` / `?.` points. The `!!` not-null assertion has + * no dedicated mutator, so it produces none. */ class NullSafetyTargetTest { @@ -28,7 +31,7 @@ class NullSafetyTargetTest { } @Test - fun `null-safety operators produce no mutation points`() { + fun `null-safety operators produce only their dedicated mutation points`() { MutFlow.underTest(run = 0, selection = Selection.MostLikelyStable, shuffle = Shuffle.PerChange) { target.elvis(null, 7) } @@ -42,10 +45,14 @@ class NullSafetyTargetTest { 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}" } + // elvis (`?:`) and safe-call (`?.`) each produce exactly one dedicated point; + // the `!!` not-null assertion has no mutator so produces none. Crucially, the + // synthetic `x == null` checks must NOT yield spurious equality-swap points + // (EqualitySwapOperator skips null comparisons) — so the total is exactly 2. + assertEquals( + 2, + points.size, + "expected exactly elvis + safe-call points (no equality swaps), got: $points" ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 571c731..d3bd559 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ pluginManagement { val kotlinVersion: String by settings plugins { kotlin("jvm") version kotlinVersion + kotlin("multiplatform") version kotlinVersion } } @@ -14,3 +15,4 @@ include("mutflow-compiler-plugin") include("mutflow-junit6") include("mutflow-gradle-plugin") include("mutflow-test-sample") +include("mutflow-test-kmp") diff --git a/tools/mutflow-inspect/.gitignore b/tools/mutflow-inspect/.gitignore new file mode 100644 index 0000000..b277351 --- /dev/null +++ b/tools/mutflow-inspect/.gitignore @@ -0,0 +1,2 @@ +# Build artifacts (regenerated by inspect-all.sh) +*.html diff --git a/tools/mutflow-inspect/README.md b/tools/mutflow-inspect/README.md new file mode 100644 index 0000000..a0eaf16 --- /dev/null +++ b/tools/mutflow-inspect/README.md @@ -0,0 +1,73 @@ +# mutflow-inspect + +Runs the **mutated binary** against a battery of test inputs on **every KMP +target** (JVM, JS, WASM, Native linuxX64, Native macosArm64) and renders an HTML +dashboard showing which mutants are **killed** vs **survived**, with per-input +detail (baseline → mutant) so you can see *why* each mutant changed behavior. + +This inspects the actual compiled artifact — the `.class`/`.js`/`.wasm`/native +code that the mutflow compiler plugin injected `MutationRegistry.check(...)` +guards into — not the source or an isolated test harness. + +## Usage + +```bash +./inspect-all.sh # build + run all 5 targets + write report-all.html +./inspect-all.sh --no-build # skip the Gradle build (reuse existing classes) +``` + +Kotlin/Native only builds/runs targets compatible with the host you're on (e.g. +`macosArm64` needs a macOS host, `linuxX64` a Linux host), so whichever native +target doesn't match your machine shows up as "no results (target did not run)" +in the dashboard rather than failing the run. + +Then open `report-all.html` in a browser. It shows a per-platform summary table +(variants / killed / survived / kill rate) plus a full per-platform detail table +of every point × variant with a KILLED/SURVIVED badge and the exact inputs whose +results changed. + +## What it does + +1. Builds the mutated KMP classes for every target + (`:mutflow-test-kmp:compileKotlin{Jvm,Js,WasmJs,LinuxX64,MacosArm64}`). +2. Runs `sample.PlatformInspectorTest` on each target. The test runs a + **baseline** session over a battery of inputs to discover every mutation + point and record the expected (original) results, then for **each point × + each variant** runs the same battery with that mutant active. A variant is + **killed** if any input's result differs from baseline; otherwise it + **survived**. +3. Each target writes its results to `inspect-results/.json` in its + own working directory (JVM/native: `mutflow-test-kmp/`; JS/WASM: the package + dir under `build/`). The two native targets share their file-writing code + (`nativeTest`) and only differ in the platform name they report + (`linuxX64Test`/`macosArm64Test`). +4. `inspect-all.sh` globs the repo for those JSON files (preferring the most + recently written copy per platform) and aggregates them into one + `report-all.html`. + +## Files + +- `PlatformInspectorTest.kt` (in `mutflow-test-kmp/src/commonTest`) — the + multiplatform inspector; the battery lives here. Add/remove inputs to + broaden or narrow coverage. +- `PlatformInspectorActual.{jvm,js,wasm}.kt` — per-target file I/O. +- `PlatformInspectorActual.native.kt` (in `mutflow-test-kmp/src/nativeTest`) — + file I/O shared by both Kotlin/Native targets. +- `PlatformInspectorActual.{linuxX64,macosArm64}.kt` — the per-native-target + platform name only. +- `inspect-all.sh` — build + run all targets + aggregate into `report-all.html`. +- `report-all.html` — generated dashboard (regenerated on each run). + +## Notes + +- Inputs that throw are recorded as `CRASH:` rather than aborting the + run, so a crashing mutant is still reported (and counted as killed). +- The `ConstructorCallOperator` deliberately skips constructor calls for types in + its `UNSAFE_NULL_DEREF_TYPES` list (currently just `Regex`, whose matching + delegates to the native/JS regex engine): replacing such an object with `null` + makes the subsequent method call a null-deref that is a catchable NPE on JVM/JS + but an **uncatchable segfault** on Kotlin/Native and Kotlin/Wasm, which would + kill the whole test process. There's no reliable way to detect this from IR + alone, so add to that list if another type is found to crash the same way. +- The tool targets the KMP `sample.Calculator` artifact. Point it at another + module's mutated classes by editing the build tasks in `inspect-all.sh`. diff --git a/tools/mutflow-inspect/inspect-all.sh b/tools/mutflow-inspect/inspect-all.sh new file mode 100755 index 0000000..05acb45 --- /dev/null +++ b/tools/mutflow-inspect/inspect-all.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# mutflow-inspect-all: run the mutated binary on EVERY KMP target (JVM, JS, +# WASM, Native linuxX64, Native macosArm64) and aggregate the per-platform JSON +# results into one HTML dashboard so you can see killed vs survived mutants by +# eye, per platform. Kotlin/Native only builds/runs targets compatible with the +# current host (e.g. macosArm64 requires a macOS host, linuxX64 a Linux host), +# so whichever native target doesn't match this machine is skipped, not failed. +# +# Usage: +# ./inspect-all.sh # build + run all targets + write report-all.html +# ./inspect-all.sh --no-build # skip the Gradle build (reuse existing classes) +# +# Output: report-all.html in this directory (open in a browser). +set -euo pipefail +cd "$(dirname "$0")" + +ROOT="$(cd ../.. && pwd)" +OUT="report-all.html" +RESULTS_DIR="$ROOT/mutflow-test-kmp/build/inspect-results" + +# 1. Build the mutated KMP classes for every target (unless --no-build). +if [[ "${1:-}" != "--no-build" ]]; then + echo "[inspect-all] building mutated KMP classes for all targets..." + (cd "$ROOT" && ./gradlew \ + :mutflow-test-kmp:compileKotlinJvm \ + :mutflow-test-kmp:compileKotlinJs \ + :mutflow-test-kmp:compileKotlinWasmJs \ + :mutflow-test-kmp:compileKotlinLinuxX64 \ + :mutflow-test-kmp:compileKotlinMacosArm64 \ + --console=plain -q) +fi + +# 2. Run the inspector test on each target. Each writes inspect-results/.json. +# --rerun-tasks forces the test to actually re-run (and rewrite its JSON) even +# when Gradle considers the task up-to-date. +echo "[inspect-all] running inspector on JVM..." +(cd "$ROOT" && ./gradlew :mutflow-test-kmp:jvmTest --tests 'sample.PlatformInspectorTest' --rerun-tasks --console=plain -q) + +echo "[inspect-all] running inspector on JS (node)..." +(cd "$ROOT" && ./gradlew :mutflow-test-kmp:jsNodeTest --tests 'sample.PlatformInspectorTest' --rerun-tasks --console=plain -q) + +echo "[inspect-all] running inspector on WASM (node)..." +(cd "$ROOT" && ./gradlew :mutflow-test-kmp:wasmJsNodeTest --tests 'sample.PlatformInspectorTest' --rerun-tasks --console=plain -q) + +echo "[inspect-all] running inspector on Native (linuxX64)..." +(cd "$ROOT" && ./gradlew :mutflow-test-kmp:linuxX64Test --tests 'sample.PlatformInspectorTest' --rerun-tasks --console=plain -q) + +echo "[inspect-all] running inspector on Native (macosArm64)..." +(cd "$ROOT" && ./gradlew :mutflow-test-kmp:macosArm64Test --tests 'sample.PlatformInspectorTest' --rerun-tasks --console=plain -q) + +# 3. Aggregate the JSON files into one HTML dashboard. +echo "[inspect-all] aggregating results..." +python3 - "$ROOT" "$OUT" <<'PY' +import json, os, sys, html + +root, out = sys.argv[1], sys.argv[2] +platforms = ["jvm", "js", "wasmJs", "linuxX64", "macosArm64"] +data = {} +# Each target writes inspect-results/.json into its own working dir +# (JVM/native: mutflow-test-kmp/build/inspect-results; JS/WASM: the package dir). +# Glob the whole repo for the files. +found = {} +for dirpath, dirnames, filenames in os.walk(root): + if "inspect-results" in dirnames: + d = os.path.join(dirpath, "inspect-results") + for f in os.listdir(d): + if f.endswith(".json"): + p = f[:-5] + full = os.path.join(d, f) + # Prefer the most recently written file for each platform (stale + # copies can linger in build/ dirs from earlier runs). + if p not in found or os.path.getmtime(full) > os.path.getmtime(found[p]): + found[p] = full +for p in platforms: + path = found.get(p) + if path and os.path.exists(path): + with open(path) as f: + data[p] = json.load(f) + else: + data[p] = None + +def esc(s): + return html.escape(str(s)) + +rows = [] +for p in platforms: + d = data[p] + if d is None: + rows.append(f"{p}no results (target did not run)") + continue + variants = d.get("variants", []) + killed = sum(1 for v in variants if v.get("killed")) + survived = len(variants) - killed + kill_rate = f"{100.0*killed/len(variants):.1f}%" if variants else "n/a" + rows.append( + f"{p}" + f"{len(variants)}{killed}{survived}" + f"{kill_rate}" + f"{esc('; '.join(v['operator'] for v in variants[:8]))}{' …' if len(variants)>8 else ''}" + ) + +# Per-platform detail tables. +detail = [] +for p in platforms: + d = data[p] + if d is None: + continue + variants = d.get("variants", []) + detail.append(f"

{p}

") + for v in variants: + badge = "k" if v.get("killed") else "s" + label = "KILLED" if v.get("killed") else "SURVIVED" + detail.append( + f"" + f"" + f"" + f"" + f"" + f"" + ) + detail.append("
PointVariantOperatorLocationStatusDetail
{esc(v.get('pointId'))}{v.get('variant')}{esc(v.get('operator'))}{esc(v.get('location'))}{label}{esc(v.get('detail',''))}
") + +html_doc = f""" +mutflow multiplatform binary inspection + +

mutflow multiplatform binary inspection

+

Mutated classes: sample.Calculator · targets: JVM, JS, WASM, Native (linuxX64), Native (macosArm64)

+ +{''.join(rows)} +
PlatformVariantsKilledSurvivedKill rateOperators
+{''.join(detail)} +""" + +with open(out, "w") as f: + f.write(html_doc) +print(f"[inspect-all] wrote {out}") +for p in platforms: + d = data[p] + if d is None: + print(f"[inspect-all] {p}: no results") + continue + variants = d.get("variants", []) + killed = sum(1 for v in variants if v.get("killed")) + print(f"[inspect-all] {p}: {killed}/{len(variants)} killed") +PY