From 021115c22e608d598da8796d80003eddfe013a45 Mon Sep 17 00:00:00 2001 From: "agent:orchestrator" Date: Thu, 15 Oct 2026 19:12:08 +0200 Subject: [PATCH 1/2] fix: restore coverage paths when the covered files share only the root directory Since phpunit/php-code-coverage 14, `--coverage-php` writes the file keys relative to `basePath`. When the covered files share only the root directory (e.g. /tmp/x/A.php and /home/y/B.php), the path reducer returns an empty base path and the keys lose their leading separator, so they no longer match the real paths of the mutated files: every mutation becomes uncovered and `--covered-only` skips the files. Co-Authored-By: Claude Opus 5.5 --- src/Support/CoveragePaths.php | 41 +++++++++++++++++++ src/Tester/MutationTestRunner.php | 8 +--- tests/Unit/Support/CoveragePathsTest.php | 50 ++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 src/Support/CoveragePaths.php create mode 100644 tests/Unit/Support/CoveragePathsTest.php diff --git a/src/Support/CoveragePaths.php b/src/Support/CoveragePaths.php new file mode 100644 index 0000000..e56fd8c --- /dev/null +++ b/src/Support/CoveragePaths.php @@ -0,0 +1,41 @@ +coveredFiles() as $relativePath) { + $coverageData->renameFile($relativePath, self::absolute($relativePath, $basePath)); + } + } + + /** + * @param non-empty-string $relativePath + * @return non-empty-string + */ + private static function absolute(string $relativePath, string $basePath): string + { + if ($basePath !== '') { + return $basePath.DIRECTORY_SEPARATOR.$relativePath; + } + + // the base path is also empty when the covered files share only the root directory, + // and their keys then lost the leading separator; files without a common ancestor at + // all (e.g. on different Windows drives) keep their absolute paths + if (preg_match('/^[A-Za-z]:/', $relativePath) === 1) { + return $relativePath; + } + + return DIRECTORY_SEPARATOR.$relativePath; + } +} diff --git a/src/Tester/MutationTestRunner.php b/src/Tester/MutationTestRunner.php index b451574..e69e78d 100644 --- a/src/Tester/MutationTestRunner.php +++ b/src/Tester/MutationTestRunner.php @@ -14,6 +14,7 @@ use Pest\Mutate\Repositories\ConfigurationRepository; use Pest\Mutate\Repositories\TelemetryRepository; use Pest\Mutate\Support\Configuration\Configuration; +use Pest\Mutate\Support\CoveragePaths; use Pest\Mutate\Support\FileFinder; use Pest\Mutate\Support\MutationGenerator; use Pest\Support\Container; @@ -121,13 +122,8 @@ public function run(): int // since phpunit/php-code-coverage 14, `--coverage-php` writes an array instead of a // serialized CodeCoverage, and its file keys are relative to `basePath` $coverageData = $loadedCoverage['codeCoverage']; - $basePath = $loadedCoverage['basePath']; - if ($basePath !== '') { - foreach ($coverageData->coveredFiles() as $relativePath) { - $coverageData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath); - } - } + CoveragePaths::restore($coverageData, $loadedCoverage['basePath']); } $coveredLines = $this->coveredLines($coverageData); diff --git a/tests/Unit/Support/CoveragePathsTest.php b/tests/Unit/Support/CoveragePathsTest.php new file mode 100644 index 0000000..18f636d --- /dev/null +++ b/tests/Unit/Support/CoveragePathsTest.php @@ -0,0 +1,50 @@ +restore = function (array $files): array { + $coverageData = new ProcessedCodeCoverageData; + $coverageData->setLineCoverage(array_fill_keys($files, [1 => [0 => 1]])); + + // reduces the paths the same way `--coverage-php` does before writing the report + $basePath = new PathReducer()->reduce($coverageData); + + CoveragePaths::restore($coverageData, $basePath); + + return $coverageData->coveredFiles(); + }; +}); + +it('restores the absolute paths of the covered files', function (array $files): void { + expect(($this->restore)($files))->toEqualCanonicalizing($files); +})->with([ + 'single file' => [[ + DIRECTORY_SEPARATOR.'project'.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'A.php', + ]], + 'files sharing a directory' => [[ + DIRECTORY_SEPARATOR.'project'.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'A.php', + DIRECTORY_SEPARATOR.'project'.DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'B.php', + ]], + 'files sharing only the root directory' => [[ + DIRECTORY_SEPARATOR.'project'.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'A.php', + DIRECTORY_SEPARATOR.'tmp'.DIRECTORY_SEPARATOR.'B.php', + ]], +]); + +it('restores the absolute paths of the covered files on windows', function (array $files): void { + expect(($this->restore)($files))->toEqualCanonicalizing($files); +})->with([ + 'files sharing only the drive' => [[ + 'C:\\project\\src\\A.php', + 'C:\\tmp\\B.php', + ]], + 'files on different drives' => [[ + 'C:\\project\\src\\A.php', + 'D:\\tmp\\B.php', + ]], +])->onlyOnWindows(); From 2f28b952ad4cb2574ea47a1c98f890aa5bbfa5e3 Mon Sep 17 00:00:00 2001 From: "agent:orchestrator" Date: Thu, 15 Oct 2026 20:48:11 +0200 Subject: [PATCH 2/2] test: mutate covered files sharing only the root directory end to end Runs `--mutate` with and without `--covered-only` for a class generated in the temporary directory and covered together with a class of the repository. Skipped on Windows, where the file finder takes the paths with a drive letter for relative paths. Co-Authored-By: Claude Opus 5.5 --- tests/.tests/CoveragePaths/SharedRootTest.php | 17 ++++ tests/Features/CoveragePathsTest.php | 86 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/.tests/CoveragePaths/SharedRootTest.php create mode 100644 tests/Features/CoveragePathsTest.php diff --git a/tests/.tests/CoveragePaths/SharedRootTest.php b/tests/.tests/CoveragePaths/SharedRootTest.php new file mode 100644 index 0000000..959f734 --- /dev/null +++ b/tests/.tests/CoveragePaths/SharedRootTest.php @@ -0,0 +1,17 @@ +toBeTrue() + ->and(Threshold::reached(9))->toBeFalse(); +}); + +it('checks a class inside the repository', function (): void { + expect(AgeHelper::isSenior(65))->toBeTrue(); +}); diff --git a/tests/Features/CoveragePathsTest.php b/tests/Features/CoveragePathsTest.php new file mode 100644 index 0000000..23a87bf --- /dev/null +++ b/tests/Features/CoveragePathsTest.php @@ -0,0 +1,86 @@ +directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-mutate-coverage-paths'; + + if (! is_dir($this->directory)) { + mkdir($this->directory); + } + + file_put_contents($this->directory.DIRECTORY_SEPARATOR.'Threshold.php', <<<'PHP' + = 10; + } + } + PHP); + + file_put_contents($this->directory.DIRECTORY_SEPARATOR.'phpunit.xml', << + + + + {$root}/tests/.tests/CoveragePaths + + + + + {$root}/tests/Fixtures/Classes + {$this->directory} + + + + XML); +}); + +it('mutates the covered files that share only the root directory', function (array $arguments): void { + $process = new Process( + [ + PHP_BINARY, + 'vendor/bin/pest', + '--configuration='.$this->directory.DIRECTORY_SEPARATOR.'phpunit.xml', + 'tests/.tests/CoveragePaths', + '--mutate', + '--mutator=EqualityGreaterOrEqualToGreater', + // mutates() would make Pest write a lock file shared with this test suite + '--class='.Threshold::class, + '--colors=never', + ...$arguments, + ], + dirname(__DIR__, 2), + [ + 'COLUMNS' => '120', + 'XDEBUG_MODE' => 'coverage', + 'PEST_PLUGIN_INTERNAL_TEST_SUITE' => '1', + 'PEST_MUTATE_GENERATED_COVERAGE_PATHS' => $this->directory, + // when this test suite runs in parallel, the mutation tests must not run as a worker + 'PARATEST' => false, + ], + timeout: 120, + ); + + $process->run(); + + $output = $process->getOutput().$process->getErrorOutput(); + + expect(preg_match('/Mutations: *(.+)/', $output, $matches))->toBe(1, $output) + ->and(trim($matches[1] ?? ''))->toBe('1 tested', $output); +})->with([ + 'all the mutations' => [[]], + 'covered mutations only' => [['--covered-only']], +])->skipOnWindows(); // the file finder takes the paths with a drive letter for relative paths