From 07416a3c61268acf867a77bcfc2861a4c4c0c728 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 1 May 2026 03:30:28 +0100 Subject: [PATCH] wip --- src/Plugins/Tia.php | 77 +++++++++++++++++++++++++++++------- src/Plugins/Tia/Recorder.php | 49 ++++++++++++++++++++++- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 6bfac3cc..9d176899 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -1250,22 +1250,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable // The collector occasionally hands us nothing usable: PHPUnit's // Prepared event can miss the file for Pest-generated classes, // and an eval'd class path (".../IndexTest.php(1) : eval()'d code") - // would be rejected later by Graph::relative(). Reflect on the - // class embedded in the test ID as a fallback so the failure - // gets stored *with* a file — without it, filtered runs lose - // the ability to re-run only the failing test next time and - // bail out to the full suite. + // would be rejected later by Graph::relative(). Recover the real + // path from the class embedded in the test ID — without it, + // filtered runs lose the ability to re-run only the failing test + // next time. if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) { - $class = strstr($testId, '::', true); - - if (is_string($class) && $class !== '') { - try { - $reflected = (new \ReflectionClass($class))->getFileName(); - $file = $reflected === false ? null : $reflected; - } catch (\ReflectionException) { - $file = null; - } - } + $file = self::resolveFailedTestFile($testId); } $graph->setResult( @@ -1283,6 +1273,63 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $collector->reset(); } + /** + * Resolves the source file for a Pest-generated test class. + * + * Pest synthesises a per-test class via `eval()` and writes the + * original test file path to a `private static $__filename` property + * (see `src/Factories/TestCaseFactory.php`). Reflecting on the class + * with `getFileName()` would return the eval'd location, which + * `Graph::relative()` rejects — losing the file mapping. + * + * Strategy: + * 1. Read the `__filename` static if the class declares it (Pest + * tests). + * 2. Otherwise use `getFileName()` and skip eval'd frames by + * walking up the parent class chain — a plain PHPUnit test + * lives in a real file at the top of that chain. + */ + private static function resolveFailedTestFile(string $testId): ?string + { + $class = strstr($testId, '::', true); + + if (! is_string($class) || $class === '') { + return null; + } + + try { + $reflection = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + if ($reflection->hasProperty('__filename')) { + try { + $filename = $reflection->getStaticPropertyValue('__filename'); + } catch (\ReflectionException) { + $filename = null; + } + + if (is_string($filename) && $filename !== '' && ! str_contains($filename, "eval()'d")) { + return $filename; + } + } + + $current = $reflection; + + while ($current !== false) { + $file = $current->getFileName(); + + if (is_string($file) && $file !== '' && ! str_contains($file, "eval()'d")) { + return $file; + } + + $current = $current->getParentClass(); + } + + return null; + } + private function coverageReportActive(): bool { try { diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index d4a5b633..2bb1a410 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -150,14 +150,28 @@ final class Recorder \pcov\stop(); /** @var array $data */ $data = \pcov\collect(\pcov\all); + + // pcov returns every executable line in every file it + // tracked: positive values for executed lines, `-1` for + // executable-but-not-run. A file with no positives was + // loaded but nothing in it ran during this test's window + // — typically a declaration-only file (Mailables, Enums, + // DTOs) pulled in by some service-provider's static `use` + // at framework boot. Including those attributes every + // globally-bootstrapped class to whichever test triggered + // the boot, blowing up the affected set on edits to those + // files. + $coveredFiles = self::filesWithExecutedLines($data); } else { /** @var array $data */ $data = \xdebug_get_code_coverage(); // `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage. \xdebug_stop_code_coverage(true); + + $coveredFiles = array_keys($data); } - foreach (array_keys($data) as $sourceFile) { + foreach ($coveredFiles as $sourceFile) { $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } @@ -172,7 +186,7 @@ final class Recorder // Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode, // so a signature change would leave implementing-class tests stale without this walk. - $this->linkSourceDependencies(array_keys($data)); + $this->linkSourceDependencies($coveredFiles); $this->currentTestFile = null; $this->includedFilesAtTestStart = []; @@ -641,6 +655,37 @@ final class Recorder return is_string($file) ? $file : null; } + /** + * Filters pcov's `file => line => executionCount` map to the files + * that actually had at least one executed line. pcov reports `-1` + * for "executable but not run" and a positive count for executed + * lines; a file with no positives was loaded but contributed no + * executed code to this test. + * + * @param array $data + * @return list + */ + private static function filesWithExecutedLines(array $data): array + { + $out = []; + + foreach ($data as $file => $lines) { + if (! is_string($file) || ! is_array($lines)) { + continue; + } + + foreach ($lines as $count) { + if (is_int($count) && $count > 0) { + $out[] = $file; + + continue 2; + } + } + } + + return $out; + } + public function reset(): void { $this->currentTestFile = null;