From 43d61cec07e4fa901919e5590788fac4884ed440 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 13:05:46 +0100 Subject: [PATCH 1/3] fix(codecov): sweep stale XML files via sentinel-file guard (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-file XMLs from a previous run were never cleaned when source files were renamed or deleted, leaving Infection (and any other consumer) with ghost entries in the output directory. Add a `.testo-coverage` sentinel file to mark the directory as ours. On every run after the first, the sentinel's presence triggers a recursive sweep of all `*.xml` files before the new set is written. If the sentinel is absent (directory not previously written by this reporter), no sweeping occurs — guarding against accidental data loss. - `generate()`: check/touch sentinel, call `sweepStaleXml()` when present - `sweepStaleXml()`: `RecursiveIteratorIterator` removes `*.xml` files only - Class-level PHPDoc documents the stale-file guard behaviour - Three new tests: re-run same set, re-run after file removed, first-run on non-empty directory without sentinel Co-Authored-By: Claude Sonnet 4.6 --- .../codecov/src/Report/PhpUnitXmlReport.php | 37 ++++++++ .../Unit/Report/PhpUnitXmlReportTest.php | 86 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/plugin/codecov/src/Report/PhpUnitXmlReport.php b/plugin/codecov/src/Report/PhpUnitXmlReport.php index 31aa16e..1eafe50 100644 --- a/plugin/codecov/src/Report/PhpUnitXmlReport.php +++ b/plugin/codecov/src/Report/PhpUnitXmlReport.php @@ -29,11 +29,25 @@ * * Distinct from JUnit XML (test-results format, see {@see \Testo\Output\JUnit\JUnitPlugin}). * + * ## Stale-file guard + * + * On the first successful run a sentinel file `.testo-coverage` is created + * inside ``. On every subsequent run, the sentinel's presence + * signals that the directory belongs to this reporter; all `*.xml` files are + * removed before the new set is written. This prevents stale entries — for + * source files that were renamed or deleted between runs — from accumulating + * inside the output directory. + * + * If `` does not contain the sentinel (e.g. it is pointed at a + * pre-existing directory unrelated to coverage), no sweeping takes place, + * protecting against accidental data loss. + * * @api */ final readonly class PhpUnitXmlReport implements CoverageReport { private const XMLNS = 'https://schema.phpunit.de/coverage/1.0'; + private const SENTINEL = '.testo-coverage'; public function __construct( /** @var non-empty-string Output directory for the report. Created if missing. */ @@ -47,6 +61,14 @@ public function generate(CoverageResult $result): void \is_dir($this->outputDir) or \mkdir($this->outputDir, 0o755, true); + // Stale-file guard: sentinel presence means this directory is ours. + // Sweep leftover *.xml files before writing the new set so that + // deleted or renamed source files don't persist across runs. + $sentinelPath = $this->outputDir . '/' . self::SENTINEL; + if (\file_exists($sentinelPath)) { + $this->sweepStaleXml(); + } + $entries = []; $totalStmts = 0; $totalCovered = 0; @@ -75,6 +97,21 @@ public function generate(CoverageResult $result): void foreach ($entries as $entry) { $this->writeFile($entry); } + + \file_put_contents($sentinelPath, ''); + } + + private function sweepStaleXml(): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->outputDir, \RecursiveDirectoryIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + \assert($file instanceof \SplFileInfo); + if ($file->isFile() && $file->getExtension() === 'xml') { + \unlink($file->getPathname()); + } + } } /** diff --git a/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php b/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php index b4af9e6..a06def1 100644 --- a/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php +++ b/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php @@ -228,6 +228,92 @@ public function fileOutsideSourceRootFlattensToSlug(): void } } + // --- Stale-file guard tests (issue #125) --- + + public function reRunSameSourceSetProducesNoStaleFiles(): void + { + $dir = self::tmpDir(); + try { + $result = (new CoverageResult([ + '/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [ + 10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']), + ]), + ]))->withSourceRoot('/project'); + + $report = new PhpUnitXmlReport($dir); + $report->generate($result); + $report->generate($result); + + // Exactly one per-file XML plus index.xml; no duplicates or leftovers. + $xmlFiles = \glob($dir . '/src/*.xml') ?: []; + Assert::count($xmlFiles, 1); + Assert::true(\file_exists($dir . '/index.xml')); + Assert::same(\count(\glob($dir . '/*.xml') ?: []), 1); // index only in root + } finally { + self::cleanup($dir); + } + } + + public function reRunAfterFileRemovedCleansStaleXml(): void + { + $dir = self::tmpDir(); + try { + $resultWithTwo = (new CoverageResult([ + '/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [ + 10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']), + ]), + '/project/src/Bar.php' => new FileCoverage('/project/src/Bar.php', [ + 10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\BarTest::testA']), + ]), + ]))->withSourceRoot('/project'); + + $resultWithOne = (new CoverageResult([ + '/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [ + 10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']), + ]), + ]))->withSourceRoot('/project'); + + $report = new PhpUnitXmlReport($dir); + $report->generate($resultWithTwo); + + Assert::true(\file_exists($dir . '/src/Foo.php.xml')); + Assert::true(\file_exists($dir . '/src/Bar.php.xml')); + + $report->generate($resultWithOne); + + // Bar.php was "deleted" — its stale XML must be gone. + Assert::true(\file_exists($dir . '/src/Foo.php.xml')); + Assert::false(\file_exists($dir . '/src/Bar.php.xml')); + } finally { + self::cleanup($dir); + } + } + + public function firstRunOnNonEmptyDirectoryWithoutSentinelLeavesExistingFilesAlone(): void + { + $dir = self::tmpDir(); + try { + // Pre-populate with a non-coverage XML that must survive untouched. + $foreignXml = $dir . '/some-other-report.xml'; + \file_put_contents($foreignXml, ''); + + $result = (new CoverageResult([ + '/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [ + 10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']), + ]), + ]))->withSourceRoot('/project'); + + (new PhpUnitXmlReport($dir))->generate($result); + + // No sentinel on first run → sweep never ran → foreign file untouched. + Assert::true(\file_exists($foreignXml)); + Assert::true(\file_exists($dir . '/index.xml')); + Assert::true(\file_exists($dir . '/src/Foo.php.xml')); + } finally { + self::cleanup($dir); + } + } + private static function tmpDir(): string { $dir = \dirname(__DIR__, 2) . '/runtime/testo_phpunit_xml_' . \uniqid(); From 1b59307e9ac25c838bee9f4a6a0d86a5d0863582 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 13:20:53 +0100 Subject: [PATCH 2/3] test(codecov): kill 3 escaped Infection mutants in sentinel/sweep code - Concat + ConcatOperandRemoval on sentinelPath: both mutations write the sentinel alongside outputDir rather than inside it (e.g. /tmp/abc.testo- coverage vs /tmp/abc/.testo-coverage). New test sentinelIsWrittenInsideOutputDir asserts file_exists($dir . '/.testo-coverage') and !is_file($dir . '.testo-coverage'), failing when either concat mutation is applied. - LogicalAnd (isFile() && getExtension() === 'xml'): escaped because RecursiveIteratorIterator::LEAVES_ONLY only yields files, making isFile() always true and && vs || equivalent. Removed the redundant isFile() check; added a comment explaining the invariant. No logical change; Infection can no longer apply LogicalAnd where there is no boolean operator. Co-Authored-By: Claude Sonnet 4.6 --- .../codecov/src/Report/PhpUnitXmlReport.php | 3 ++- .../Unit/Report/PhpUnitXmlReportTest.php | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugin/codecov/src/Report/PhpUnitXmlReport.php b/plugin/codecov/src/Report/PhpUnitXmlReport.php index 1eafe50..a1f2a15 100644 --- a/plugin/codecov/src/Report/PhpUnitXmlReport.php +++ b/plugin/codecov/src/Report/PhpUnitXmlReport.php @@ -103,12 +103,13 @@ public function generate(CoverageResult $result): void private function sweepStaleXml(): void { + // LEAVES_ONLY (default) yields only files, never directories. $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($this->outputDir, \RecursiveDirectoryIterator::SKIP_DOTS), ); foreach ($iterator as $file) { \assert($file instanceof \SplFileInfo); - if ($file->isFile() && $file->getExtension() === 'xml') { + if ($file->getExtension() === 'xml') { \unlink($file->getPathname()); } } diff --git a/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php b/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php index a06def1..885c3ef 100644 --- a/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php +++ b/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php @@ -230,6 +230,28 @@ public function fileOutsideSourceRootFlattensToSlug(): void // --- Stale-file guard tests (issue #125) --- + public function sentinelIsWrittenInsideOutputDir(): void + { + $dir = self::tmpDir(); + try { + $result = (new CoverageResult([ + '/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [ + 10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']), + ]), + ]))->withSourceRoot('/project'); + + (new PhpUnitXmlReport($dir))->generate($result); + + // Sentinel must be a child of outputDir, not a sibling alongside it. + Assert::true(\file_exists($dir . '/.testo-coverage')); + Assert::false(\is_file($dir . '.testo-coverage')); + } finally { + self::cleanup($dir); + // Clean up the alongside-dir sentinel if the mutant wrote it. + \is_file($dir . '.testo-coverage') and \unlink($dir . '.testo-coverage'); + } + } + public function reRunSameSourceSetProducesNoStaleFiles(): void { $dir = self::tmpDir(); From 47372fb5d1821628409c0e12a3a4b8fca98a5649 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 13:48:44 +0100 Subject: [PATCH 3/3] fix(phpunit-mirror): add .placeholder.php so EmptyRun stub directory is mirrored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/Application/Stub/EmptyRun/ is intentionally empty — it is the test fixture for EmptyRunTest, which asserts that a Testo run over an empty directory yields Status::Risky with zero tests collected. Git does not track empty directories, and bin/build-phpunit.php only copies *.php files when populating the tests/PhpUnit/ mirror, so the mirror never contained tests/PhpUnit/Application/Stub/EmptyRun/. The mirrored EmptyRunTest resolved __DIR__ . '/../../Stub/EmptyRun' to that missing path and threw InvalidArgumentException: File or directory not found — aborting Infection's initial PHPUnit test run on every CI push to 1.x. Add .placeholder.php (no namespace, no classes, no tests) to the source directory. The build script copies it verbatim into the mirror, which creates the required directory. Testo's FinderConfig still discovers zero tests there, so Status::Risky is reported and the assertion holds. Co-Authored-By: Claude Sonnet 4.6 --- tests/Application/Stub/EmptyRun/.placeholder.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/Application/Stub/EmptyRun/.placeholder.php diff --git a/tests/Application/Stub/EmptyRun/.placeholder.php b/tests/Application/Stub/EmptyRun/.placeholder.php new file mode 100644 index 0000000..72680ed --- /dev/null +++ b/tests/Application/Stub/EmptyRun/.placeholder.php @@ -0,0 +1,10 @@ +