Skip to content

fix(codecov): sweep stale XML files via sentinel-file guard (#125)#260

Open
rossaddison wants to merge 3 commits into
php-testo:1.xfrom
rossaddison:fix/125-stale-xml-sentinel-guard
Open

fix(codecov): sweep stale XML files via sentinel-file guard (#125)#260
rossaddison wants to merge 3 commits into
php-testo:1.xfrom
rossaddison:fix/125-stale-xml-sentinel-guard

Conversation

@rossaddison

Copy link
Copy Markdown
Contributor

Problem

PhpUnitXmlReport::generate() writes per-file XMLs into <outputDir> but never cleans old files between runs. When a source file is renamed or deleted, its corresponding XML remains, leaving Infection (and any other consumer) with ghost entries.

Approach — sentinel-file guard

On the first successful run a file .testo-coverage is created inside <outputDir>. On every subsequent run, if the sentinel is present the directory is known to belong to this reporter, so all *.xml files are removed before the new set is written.

If the sentinel is absent (e.g. <outputDir> is pointed at an unrelated directory), no sweeping takes place — protecting against accidental data loss.

This is the approach suggested by the maintainer in issue #125.

Changes

  • PhpUnitXmlReport::generate() — check/touch sentinel, call sweepStaleXml() when present
  • PhpUnitXmlReport::sweepStaleXml()RecursiveIteratorIterator deletes *.xml files only (non-XML files untouched)
  • private const SENTINEL = '.testo-coverage' — name is a constant for easy discoverability
  • Class-level PHPDoc updated to document the stale-file guard behaviour
  • Three new tests covering the acceptance criteria from the issue:
    • reRunSameSourceSetProducesNoStaleFiles — idempotent re-runs don't accumulate files
    • reRunAfterFileRemovedCleansStaleXml — deleted source file's XML is removed on next run
    • firstRunOnNonEmptyDirectoryWithoutSentinelLeavesExistingFilesAlone — no sweep without sentinel

Test results

Suite: Codecov/Unit — 119 tests · 119 passed (0 skipped/failed)

All pre-existing tests pass unchanged; no index.xml content or <file href> semantics were altered.

Fixes #125

…o#125)

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 <noreply@anthropic.com>
@rossaddison rossaddison requested a review from a team as a code owner July 6, 2026 12:06
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

rossaddison and others added 2 commits July 6, 2026 13:20
- 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 <noreply@anthropic.com>
…is mirrored

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 <noreply@anthropic.com>
@rossaddison

Copy link
Copy Markdown
Contributor Author

PR Summary

Three commits, two problems fixed.

Issue #125 — stale XML files in PhpUnitXmlReport

generate() wrote per-file XMLs into <outputDir> but never cleaned up between runs. When a source file was renamed or deleted, its XML persisted — leaving Infection (and any other consumer) with ghost entries.

Fix: sentinel-file guard in PhpUnitXmlReport::generate().

  • On the first successful run, .testo-coverage is written inside <outputDir>.
  • On every subsequent run, the sentinel's presence signals the directory is ours; all *.xml files are swept before the new set is written.
  • If the sentinel is absent (directory not previously written by this reporter), no sweeping occurs — protecting against accidental data loss.

New tests (3):

  • reRunSameSourceSetProducesNoStaleFiles — idempotent re-runs don't accumulate files
  • reRunAfterFileRemovedCleansStaleXml — deleted source file's XML is gone on the next run
  • firstRunOnNonEmptyDirectoryWithoutSentinelLeavesExistingFilesAlone — foreign files survive when no sentinel is present

Infection mutant hardening

Three mutants escaped the initial run:

Mutant Root cause Fix
Concat / ConcatOperandRemoval on $sentinelPath Both mutations place the sentinel alongside outputDir (e.g. /tmp/abc.testo-coverage) rather than inside it (/tmp/abc/.testo-coverage), but find it consistently on re-runs so the sweep still triggers sentinelIsWrittenInsideOutputDir asserts file_exists($dir . '/.testo-coverage') and !is_file($dir . '.testo-coverage')
LogicalAnd|| in sweepStaleXml() RecursiveIteratorIterator::LEAVES_ONLY only yields files, so isFile() is always true and && vs || are equivalent Removed the redundant isFile() check; added a comment explaining the invariant

Pre-existing CI fix — EmptyRunTest in PHPUnit mirror

tests/Application/Stub/EmptyRun/ is an intentionally empty directory (only .gitkeep). The PHPUnit mirror build (bin/build-phpunit.php) copies only *.php files, so tests/PhpUnit/Application/Stub/EmptyRun/ was never created in CI. The mirrored EmptyRunTest resolves __DIR__ . '/../../Stub/EmptyRun' to that missing path and threw InvalidArgumentException, aborting Infection's initial PHPUnit run on every push to 1.x since 2026-07-03.

Fix: added .placeholder.php (no namespace, no classes, no tests) to the source directory. The build script copies it into the mirror, creating the required directory. Testo's FinderConfig still discovers zero tests there, so Status::Risky is reported and the existing assertion holds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PhpUnitXmlReport: stale files persist across runs

1 participant