This commit is contained in:
nuno maduro
2026-08-07 01:55:34 +01:00
parent 37bdf60a1c
commit 1f79660add
18 changed files with 6 additions and 316 deletions
+4 -85
View File
@@ -124,18 +124,14 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
'--compact', '--ci-build-id', '--min',
];
/**
* @var list<string>
*/
/** @var list<string> */
private const array COVERAGE_REPORT_FLAGS = [
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-openclover', '--coverage-php',
'--coverage-text', '--coverage-xml',
];
/**
* @var list<string>
*/
/** @var list<string> */
private const array PARTIAL_SELECTION_FLAGS = [
'--filter', '--exclude-filter', '--group', '--exclude-group',
'--covers', '--uses', '--testsuite', '--exclude-testsuite', '--test-suffix',
@@ -160,19 +156,10 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
/** @var array<string, int> */
private array $cachedAssertionsByTestId = [];
/**
* The status a replayed test was replayed *as*, so the write-back records
* what was cached rather than what the replay looked like from the
* outside. A cached deprecation replays as a pass — recording that pass
* would erase the deprecation from the baseline on the very next run.
*
* @var array<string, array{status: int, message: string}>
*/
/** @var array<string, array{status: int, message: string}> */
private array $cachedStatusByTestId = [];
/**
* @var array<string, float>
*/
/** @var array<string, float> */
private array $cachedTimeByTestId = [];
private ?Graph $replayGraph = null;
@@ -272,11 +259,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return $graph;
}
/**
* Drop a graph that will not decode, so the next run that can record starts
* clean instead of tripping over the same file forever — rebuilding needs a
* coverage driver, and without one the file would stay corrupt for good.
*/
private function discardUnreadableGraph(): void
{
if (Parallel::isWorker()) {
@@ -297,16 +279,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$this->renderBadge('WARN', 'The dependency graph could not be read — it will be rebuilt.');
}
/**
* Delete a state file, unless this checkout may not write.
*
* A detached HEAD names no branch, so {@see self::saveGraph()} refuses to
* write — which means anything deleted here could never be rebuilt from
* this checkout. Read-only has to mean deletes too, or a drifted
* `composer.lock` on a detached CI checkout wipes the whole team's baseline.
*
* @return bool Whether the delete happened.
*/
private function deleteState(string $key): bool
{
if ($this->detachedHead) {
@@ -318,9 +290,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
private function saveGraph(Graph $graph): bool
{
// A detached HEAD names no branch of its own, so `$this->branch` is the
// fallback — writing here would land this checkout's results in the
// default branch's baseline. Leave the graph exactly as it was.
if ($this->detachedHead) {
return true;
}
@@ -580,9 +549,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$this->flushWorkerReplay();
}
// `terminate()` also runs from the shutdown handler, which is how a run
// that `exit()`s inside a test gets here — with a test prepared and
// never finished, and so with no right to a complete write.
if ($this->writesSuppressed || $this->resultsOnlyWrites || $this->hasUnfinishedTest()) {
$this->recorder->reset();
$this->coverageCollector->reset();
@@ -695,16 +661,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return $exitCode;
}
// Re-anchor the baseline. Reaching here means the run was complete —
// nothing suppressed, narrowed or truncated it — so its results are the
// truth at HEAD and the recorded revision may say so.
//
// That matters most when the recorded commit had become unreachable (a
// rebase, a force-push) and no coverage driver was available to rebuild:
// without this the stale revision survives, and every later run warns
// and re-runs the whole suite, for good. Stale edges are no objection —
// a complete run just re-recorded every result, and later changes are
// compared against the revision written here.
if ($this->replayRan || $this->graphUnreachable) {
$this->bumpRecordedSha();
}
@@ -852,9 +808,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
try {
$this->resolveBranch($projectRoot);
} catch (MissingDependency $missingGit) {
// Every git call TIA makes fails on `HEAD` in a repository that has
// no commits yet, which reads as "git is missing" when git is right
// there. Say what is actually wrong instead.
$repository = new ChangedFiles($projectRoot);
if ($repository->isRepository() && ! $repository->hasCommits()) {
@@ -913,21 +866,12 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$this->state->write(self::KEY_COVERAGE_MARKER, '');
}
// An active coverage report owns the driver, so edges have to be
// piggybacked off its session — and that session is scoped to
// phpunit.xml's <source>, not to the whole project. Refreshing an
// existing graph that way is safe (`replaceEdges()` keeps what it
// already has), but *founding* one on it is not: every source file
// outside the coverage scope would be missing from the graph for good,
// and a change to one of them would select nothing and replay a pass.
if (! $graph instanceof Graph && $this->piggybackCoverage) {
$this->emitCoverageScopedRecordSkipped();
return $arguments;
}
// Past the guard above, a coverage-owned run always has a graph to
// refresh — a run without one never gets here.
if ($coverageCacheOwned && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
if ($this->driftLabel === null) {
$this->freshGraphReason = 'recording a coverage baseline';
@@ -1392,15 +1336,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
}
/**
* A parallel run keeps its results in the workers, so the parent's collector
* is empty and nothing would ever reach the graph. Ask the workers to flush
* what they ran, so a parallel run refreshes — and prunes — exactly like the
* sequential run of the same command.
*
* Gated on a graph already existing: a project that has never run TIA must
* not gain a baseline from a plain `--parallel` run.
*/
private function requestWorkerResults(): void
{
if (Parallel::isWorker() || ! Parallel::isEnabled() || $this->writesSuppressed) {
@@ -1772,16 +1707,8 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$collector->reset();
}
/**
* Give back what the graph no longer needs. Only ever called from a
* complete write — the RESULTS-ONLY and HARD-SUPPRESSED tiers may not
* remove an entry, and a narrowed run has not seen enough to judge.
*/
private function reclaim(Graph $graph): void
{
// The fallback branch never layers under itself, so marking it would
// write the graph for no reader's benefit — and cost a clean green run
// its "wrote nothing at all".
if ($this->branch !== $this->fallbackBranch) {
$graph->markBaselineComplete($this->branch);
}
@@ -1795,10 +1722,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return;
}
// A shallow, single-branch CI checkout can see almost no refs, and
// "git has never heard of it" would then mean "this clone is narrow",
// not "that branch is gone". Only reclaim from a checkout that can at
// least see the branch everything else falls back to.
if (! in_array($this->fallbackBranch, $branches, true)) {
return;
}
@@ -1966,10 +1889,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return TestResultFacade::shouldStop();
}
/**
* A test that was prepared and never finished means this process is being
* torn down mid-file, so it has not seen enough of that file to prune it.
*/
private function hasUnfinishedTest(): bool
{
$collector = Container::getInstance()->get(ResultCollector::class);
+1 -16
View File
@@ -244,11 +244,7 @@ final readonly class ChangedFiles
}
/**
* Every branch name this checkout knows, local and remote alike. Remotes
* count: a branch that only lives on the origin is still a branch someone
* will check out, and its baseline must survive.
*
* @return list<string>|null `null` when git cannot answer.
* @return list<string>|null
*/
public function branchNames(): ?array
{
@@ -307,13 +303,6 @@ final readonly class ChangedFiles
return $process->getExitCode() === 0;
}
/**
* Whether this repository has a revision to anchor a baseline to.
*
* A freshly initialised repository has none, and every other git call TIA
* makes — {@see self::currentBranch()}, {@see self::currentSha()} — fails on
* `HEAD` there and reports git as missing, which it is not.
*/
public function hasCommits(): bool
{
$process = new Process(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'], $this->projectRoot);
@@ -357,10 +346,6 @@ final readonly class ChangedFiles
*/
private function diffSinceSha(string $sha): array
{
// `--no-renames` matters: with rename detection on, git reports only the
// destination of a moved file, so the path the graph has edges for — the
// one that is gone — never reaches selection, and every test that
// depended on it replays its recorded pass.
$process = new Process(
['git', 'diff', '--name-only', '--no-renames', $sha.'..HEAD'],
$this->projectRoot,
-4
View File
@@ -29,10 +29,6 @@ enum ReplayType
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
// A recorded notice, deprecation or warning only reaches replay when
// the configured failOn* / displayDetailsOn* policies say it is not
// worth re-running — which means the test passed. Folding it into
// Failure below would turn a green run red on cache alone.
$status->isNotice(), $status->isDeprecation(), $status->isWarning() => self::Pass,
$status->isFailure(), $status->isError() => self::Failure,
default => self::None,
+1 -63
View File
@@ -120,14 +120,6 @@ final class Graph
}
/**
* Keep only the test files this checkout actually has.
*
* A stale edge key — a test file a fetched baseline knew, or one another
* branch deleted — cannot be run by anyone, and selecting it strands
* `--filtered` on a run that matches nothing and reports success on a
* change no test looked at. {@see self::testFilesToRerun()} has always
* dropped these; the change-driven half of selection must agree.
*
* @param array<int, string> $testFiles Project-relative paths.
* @return list<string>
*/
@@ -677,9 +669,6 @@ final class Graph
$r = $baseline['results'][$testId];
// A status this build does not know — a graph written by a newer Pest,
// or a corrupt one — is not a result. Returning null re-executes the
// test rather than replaying an outcome nobody can interpret.
return match ($r['status']) {
0 => TestStatus::success(),
1 => TestStatus::skipped($r['message']),
@@ -717,8 +706,6 @@ final class Graph
$rel = $this->relative($file);
// A test file that is no longer on disk cannot be re-run by anyone,
// so selecting it would only widen the run for nothing.
if ($rel !== null && is_file($this->projectRoot.'/'.$rel)) {
$files[$rel] = true;
}
@@ -727,15 +714,6 @@ final class Graph
return array_keys($files);
}
/**
* Whether a cached result due a re-run names a test file this project
* cannot address — an empty path, or one that resolves outside the project
* root. Those are genuinely lost, so the caller widens to the full suite.
*
* A path that resolves fine but is simply absent is *deleted*, not lost:
* widening would not run it either, and treating it as unlocated used to
* strand `--filtered` on a full replay for good.
*/
public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -852,24 +830,6 @@ final class Graph
}
/**
* The baseline a read sees for this branch: its own entries layered over the
* default branch's, so a key minted by a narrowed run — which only holds the
* handful of tests that ran — does not shadow the fallback for everything else.
*
* Once this branch has had a complete run, the layering becomes per *file*
* rather than per test id: the branch's entries for a file it executed are
* the whole truth, so the fallback's entries for that same file are dropped
* rather than merged. Without that, a test the branch renamed or removed —
* and {@see self::pruneStaleResults()} therefore unset — is resurrected by
* the default branch on the very next read, and never stops coming back.
*
* A branch whose key was minted by a *narrowed* run holds only the handful
* of tests that ran, and has no business speaking for the rest of their
* file, so it keeps the per-test-id merge.
*
* Read-only: the layering never reaches `$this->baselines`, so writes stay on
* the branch that ran.
*
* @return array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
*/
private function baselineFor(string $branch, ?string $fallbackBranch): array
@@ -1512,12 +1472,6 @@ final class Graph
}
}
/**
* Record that this branch has run the whole suite at least once, which is
* what lets {@see self::baselineFor()} treat its entries as authoritative
* for the files they cover. Never mints a key: a run that recorded nothing
* has nothing to be authoritative about.
*/
public function markBaselineComplete(string $branch): void
{
if (isset($this->baselines[$branch])) {
@@ -1525,12 +1479,6 @@ final class Graph
}
}
/**
* Drop this branch's result entries whose test file is no longer on disk.
*
* Without this nothing but `--fresh` ever reclaims them, and a *failing*
* one keeps `--filtered` widened to a full replay on every later run.
*/
public function pruneResultsForMissingFiles(string $branch): void
{
if (! isset($this->baselines[$branch]['results'])) {
@@ -1561,10 +1509,7 @@ final class Graph
}
/**
* Drop baselines for branches git no longer knows, so the graph does not
* carry one full copy of the suite per branch ever created.
*
* @param array<int, string> $keep Branch names that must survive.
* @param array<int, string> $keep
*/
public function pruneMissingBranches(array $keep): void
{
@@ -1722,11 +1667,6 @@ final class Graph
}
/**
* A graph is state on disk that any process may have written: a newer Pest,
* a half-finished write, a hand edit. Every branch, every entry and every
* field is checked here so that a malformed one is dropped rather than
* reaching a read path and taking the run down with it.
*
* @return array<string, array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}>
*/
private static function decodeBaselines(mixed $section): array
@@ -1738,8 +1678,6 @@ final class Graph
$baselines = [];
foreach ($section as $key => $baseline) {
// A branch named `12345` decodes as an integer key, and must not be
// mistaken for a malformed one.
$branch = (string) $key;
if ($branch === '') {
-9
View File
@@ -359,15 +359,6 @@ final class Recorder
continue;
}
// A file whose *only* executed line is its last one was included,
// not used — the trailing line of an include is all that ran.
//
// That reading only holds for a driver that reports unexecuted
// lines too: pcov returns every executable line (`-1` for the ones
// that did not run), so "the single covered line is the highest
// line reported" means something. Xdebug reports executed lines
// only, where it is true of *any* file that ran a single line —
// which is most of them, and dropping those loses the edge.
$lineKeys = array_keys($lines);
$reportsUnexecutedLines = count($covered) < count($lines);
-16
View File
@@ -38,11 +38,6 @@ final class ResultCollector
return;
}
// PHPUnit reports a test that triggered a notice, deprecation or
// warning as passed, and emits Passed for it. Recording success here
// would erase the issue from the baseline, and a later replay under
// --fail-on-deprecation (and friends) would come back green where a
// fresh run fails. Keep the issue; only refresh what it cannot know.
if (isset($this->triggered[$this->currentTestId])) {
$this->refreshTime();
@@ -120,12 +115,6 @@ final class ResultCollector
return $this->results;
}
/**
* Whether a test was prepared but never finished — the process is being
* torn down in the middle of it (an `exit()` inside a test, a killed
* worker). What it collected is therefore a partial view of that test
* file, and must not license pruning the siblings it never reached.
*/
public function hasUnfinishedTest(): bool
{
return $this->currentTestId !== null;
@@ -164,11 +153,6 @@ final class ResultCollector
$this->startTime = null;
}
/**
* Record an issue raised while the test was running. The most important
* one wins, exactly as PHPUnit ranks them, so a deprecation does not
* shadow the warning that followed it — or the failure.
*/
private function recordIssue(TestStatus $status): void
{
if ($this->currentTestId === null) {