From c4fcfd92ba0236563becc989520deb3e473f8054 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:20:26 -0800 Subject: [PATCH 01/10] Add Infection as a dev dependency Co-Authored-By: Claude Opus 5.5 --- composer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6ec1a2de..124c20f5 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "brianium/paratest": "^7", "composer/composer": "^2.10", "deptrac/deptrac": "^4.7", + "infection/infection": "0.35.9", "phpstan/phpstan": "^2.3", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", @@ -65,7 +66,8 @@ "config": { "sort-packages": true, "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": false + "dealerdirect/phpcodesniffer-composer-installer": false, + "infection/extension-installer": false } } } From 9494e76abfe8e6e3581187d5120a2f350fccc8de Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:20:44 -0800 Subject: [PATCH 02/10] Configure Infection for the unit suite Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + infection.json5 | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 infection.json5 diff --git a/.gitignore b/.gitignore index f4fc7879..b92deb61 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /composer.lock /.phpunit.cache/ /.deptrac.cache +/.infection/ /phpcs.xml diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 00000000..d4372489 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,12 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": ["src"], + }, + "tmpDir": ".infection", + "logs": { + "text": ".infection/infection.log", + }, + "staticAnalysisTool": "phpstan", + "testFrameworkExtraArgs": "--testsuite=unit", +} From a0174ecddda3380e36ff80bbbfb97bbd752134f1 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:21:01 -0800 Subject: [PATCH 03/10] Add a script to mutate changed lines Co-Authored-By: Claude Opus 5.5 --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 124c20f5..35e17711 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,10 @@ "phpstan": "phpstan analyze", "deptrac": "deptrac analyse --no-progress", "layer-coverage": "deptrac debug:unassigned", + "mutation": [ + "Composer\\Config::disableProcessTimeout", + "infection --git-diff-lines --git-diff-base=origin/main --ignore-msi-with-no-mutations" + ], "test": [ "@phpstan", "@deptrac", From 1f3f90764c32b778fb07a11e4aecc4ec59f48de9 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:33:06 -0800 Subject: [PATCH 04/10] Report surviving mutations on pull requests Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ae08c60..40462f77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,28 @@ jobs: report_type: test_results files: junit-unit.xml,junit-e2e.xml disable_search: true + + mutation: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + name: Mutation Testing + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: pcov + ini-values: zend.assertions=1 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Install fixture dependencies + run: composer install --prefer-dist --no-progress --working-dir=tests/Fixtures + + - name: Mutate changed lines + run: composer mutation From 2c9540c0841142fed191bbf8eb51f980437894b0 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:33:22 -0800 Subject: [PATCH 05/10] Document mutation testing Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0cdf03fe..008b9ff6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,18 @@ Each incomplete statement needs its own method; two in one method confuse parser `//hover:marker` puts the cursor on the last member access or function call on that line. Use it for complete code such as `$this->method()`. +### Mutation testing + +`composer mutation` makes small changes to each line of `src/` the branch changed and reports any change that neither the unit suite nor PHPStan notices. +It compares the working tree with `origin/main`, so commit first and keep `origin/main` current; a fork's `origin` must be this repository. +CI runs it on every pull request and reports what survives without failing the build. + +A surviving change is a missing test, unless no input could tell the changed code from the original. +In that case, rewrite the code so the change cannot be made. +If that is not possible, add an ignore entry for the `Class::method` (never a line number) to `infection.json5`, with the maintainer's approval in the pull request. + +A test names the behavior it checks, never the mutation it catches. + ## Code Reviews Reviewing code should focus on things not covered by CI or automated tooling: From ca97072f75d028a9f06e0d8dc09ab25011d9e2fb Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:46:46 -0800 Subject: [PATCH 06/10] Temporarily touch a weakly tested line Co-Authored-By: Claude Opus 5.5 --- src/Handler/CompletionHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Handler/CompletionHandler.php b/src/Handler/CompletionHandler.php index a44c3c87..5c251e8d 100644 --- a/src/Handler/CompletionHandler.php +++ b/src/Handler/CompletionHandler.php @@ -77,7 +77,7 @@ public function handle(Message $message): ?array */ private function capped(array $items): array { - if (count($items) <= self::RESULT_LIMIT) { + if (self::RESULT_LIMIT >= count($items)) { return ['isIncomplete' => false, 'items' => $items]; } From e3f116433eb86d764a7896fe765d6547437f2f74 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:57:12 -0800 Subject: [PATCH 07/10] Fail the mutation check while a change survives Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- CONTRIBUTING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40462f77..da5650bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,4 +80,4 @@ jobs: run: composer install --prefer-dist --no-progress --working-dir=tests/Fixtures - name: Mutate changed lines - run: composer mutation + run: composer mutation -- --min-msi=100 --min-covered-msi=100 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 008b9ff6..e1a779e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Use it for complete code such as `$this->method()`. `composer mutation` makes small changes to each line of `src/` the branch changed and reports any change that neither the unit suite nor PHPStan notices. It compares the working tree with `origin/main`, so commit first and keep `origin/main` current; a fork's `origin` must be this repository. -CI runs it on every pull request and reports what survives without failing the build. +CI runs it on every pull request and fails the check while any change survives. A surviving change is a missing test, unless no input could tell the changed code from the original. In that case, rewrite the code so the change cannot be made. From 2c132115d48a80c507c2cc263a4e373699b8c9da Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 13:59:53 -0800 Subject: [PATCH 08/10] Set mutation thresholds in config, not CI Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- CONTRIBUTING.md | 4 ++-- composer.json | 2 +- infection.json5 | 3 +++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da5650bf..40462f77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,4 +80,4 @@ jobs: run: composer install --prefer-dist --no-progress --working-dir=tests/Fixtures - name: Mutate changed lines - run: composer mutation -- --min-msi=100 --min-covered-msi=100 + run: composer mutation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1a779e1..561af086 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,9 +69,9 @@ Use it for complete code such as `$this->method()`. ### Mutation testing -`composer mutation` makes small changes to each line of `src/` the branch changed and reports any change that neither the unit suite nor PHPStan notices. +`composer mutation` makes small changes to each line of `src/` the branch changed, and fails if any change goes unnoticed by both the unit suite and PHPStan. It compares the working tree with `origin/main`, so commit first and keep `origin/main` current; a fork's `origin` must be this repository. -CI runs it on every pull request and fails the check while any change survives. +CI runs it on every pull request. A surviving change is a missing test, unless no input could tell the changed code from the original. In that case, rewrite the code so the change cannot be made. diff --git a/composer.json b/composer.json index 35e17711..dcc00583 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "layer-coverage": "deptrac debug:unassigned", "mutation": [ "Composer\\Config::disableProcessTimeout", - "infection --git-diff-lines --git-diff-base=origin/main --ignore-msi-with-no-mutations" + "infection --git-diff-lines --git-diff-base=origin/main" ], "test": [ "@phpstan", diff --git a/infection.json5 b/infection.json5 index d4372489..1b54fec6 100644 --- a/infection.json5 +++ b/infection.json5 @@ -7,6 +7,9 @@ "logs": { "text": ".infection/infection.log", }, + "minMsi": 100, + "minCoveredMsi": 100, + "ignoreMsiWithNoMutations": true, "staticAnalysisTool": "phpstan", "testFrameworkExtraArgs": "--testsuite=unit", } From 998c0dcedff270fcb0a1015e75e5537de077eb62 Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 14:04:15 -0800 Subject: [PATCH 09/10] Restore the temporarily touched line Co-Authored-By: Claude Opus 5.5 --- src/Handler/CompletionHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Handler/CompletionHandler.php b/src/Handler/CompletionHandler.php index 5c251e8d..a44c3c87 100644 --- a/src/Handler/CompletionHandler.php +++ b/src/Handler/CompletionHandler.php @@ -77,7 +77,7 @@ public function handle(Message $message): ?array */ private function capped(array $items): array { - if (self::RESULT_LIMIT >= count($items)) { + if (count($items) <= self::RESULT_LIMIT) { return ['isIncomplete' => false, 'items' => $items]; } From 0effc6d5bd99a5a22f2603e186997c59f6e5360d Mon Sep 17 00:00:00 2001 From: Eric Stern Date: Tue, 3 Nov 2026 14:05:45 -0800 Subject: [PATCH 10/10] Reword the mutation testing section Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 561af086..5a10738b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,15 +69,19 @@ Use it for complete code such as `$this->method()`. ### Mutation testing -`composer mutation` makes small changes to each line of `src/` the branch changed, and fails if any change goes unnoticed by both the unit suite and PHPStan. -It compares the working tree with `origin/main`, so commit first and keep `origin/main` current; a fork's `origin` must be this repository. -CI runs it on every pull request. +`composer mutation` runs [Infection](https://infection.github.io/) on the lines you changed in `src/`. +It fails if a mutant escapes, which means neither the unit tests nor PHPStan caught it. +CI runs the same check on pull requests. -A surviving change is a missing test, unless no input could tell the changed code from the original. -In that case, rewrite the code so the change cannot be made. -If that is not possible, add an ignore entry for the `Class::method` (never a line number) to `infection.json5`, with the maintainer's approval in the pull request. +It compares your working tree against `origin/main`, so commit and fetch before running it. +If you work from a fork, `origin` needs to be this repository. -A test names the behavior it checks, never the mutation it catches. +When a mutant escapes, add a test that catches it. +If the mutation doesn't change behavior, so no test could catch it, rewrite the code so the mutation isn't possible. +If you can't, ask in the pull request about ignoring that `Class::method` in `infection.json5`. +Don't ignore by line number. + +Name tests after the behavior they check, not the mutant they catch. ## Code Reviews