This commit is contained in:
nuno maduro
2026-05-02 15:03:44 +01:00
parent 7d51601120
commit 4a8c2d7d78
12 changed files with 57 additions and 456 deletions

View File

@ -9,7 +9,6 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\Replay;
use Pest\Preset;
@ -300,10 +299,6 @@ trait Testable
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
$autoloadBeforeSetUp = $recorder->isActive()
? AutoloadEdges::snapshot()
: [];
parent::setUp();
Collectors::armAll($recorder);
@ -315,18 +310,6 @@ trait Testable
}
$this->__callClosure($beforeEach, $arguments);
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
$recorder->linkSourcesForTest(
self::$__filename,
AutoloadEdges::newProjectFiles(
$autoloadBeforeSetUp,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
self::$__filename,
),
);
}
}
private function __shortCircuitCachedPass(): void

View File

@ -16,7 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $className, private readonly string $filename)
public function __construct(private readonly string $className, string $filename)
{
parent::__construct(sprintf(
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',

View File

@ -32,7 +32,7 @@ final class Kernel
/**
* Either the kernel is terminated or not.
*/
private bool $terminated;
private bool $terminated = false;
/**
* The Kernel bootstrappers.
@ -64,12 +64,7 @@ final class Kernel
/**
* Creates a new Kernel instance.
*/
public function __construct(
private Application $application,
private OutputInterface $output,
) {
$this->terminated = false;
}
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
/**
* Boots the Kernel.

View File

@ -631,6 +631,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! $graph instanceof Graph
&& ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift
&& $this->watchPatterns->isBaselined()
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
$this->baselineFetchAttemptedForDrift = true;
$graph = $this->loadGraph($projectRoot);
@ -1415,10 +1416,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
foreach ($arguments as $index => $arg) {
if ($arg === '' || str_starts_with($arg, '-')) {
if ($arg === '') {
continue;
}
if (str_starts_with($arg, '-')) {
continue;
}
if ($index > 0) {
$previous = $arguments[$index - 1] ?? '';
if (in_array($previous, $valueTakingFlags, true)) {
@ -1507,6 +1510,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$projectRoot = TestSuite::getInstance()->rootPath;
$this->baselineFetchAttemptedForDrift = true;
if (! $this->watchPatterns->isBaselined()) {
return null;
}
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
return null;
}

View File

@ -283,7 +283,7 @@ YAML;
/**
* @param-out string|null $failureKind
*
* @return array{graph: string, coverage: ?string}|null
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
{

View File

@ -48,6 +48,18 @@ final class Configuration
return $this;
}
/**
* @return $this
*/
public function baselined(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markBaselined();
return $this;
}
/**
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this

View File

@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
/**
* @internal
*/
final readonly class AutoloadEdges
{
/**
* @return array<string, true>
*/
public static function snapshot(): array
{
$files = [];
foreach (get_included_files() as $file) {
if ($file !== '') {
$files[$file] = true;
}
}
return $files;
}
/**
* @param array<string, true> $before
* @param array<string, true> $after
* @return list<string>
*/
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
{
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
$out = [];
foreach (array_keys($after) as $file) {
if (isset($before[$file])) {
continue;
}
$real = @realpath($file);
if ($real === false) {
$real = $file;
}
if ($testReal !== false && $real === $testReal) {
continue;
}
if (! str_starts_with($real, $root)) {
continue;
}
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
if (self::ignored($relative)) {
continue;
}
if (! str_ends_with($relative, '.php')) {
continue;
}
$out[$real] = true;
}
return array_keys($out);
}
private static function ignored(string $relative): bool
{
static $prefixes = [
'vendor/',
'node_modules/',
'storage/framework/',
'bootstrap/cache/',
];
foreach ($prefixes as $prefix) {
if (str_starts_with($relative, (string) $prefix)) {
return true;
}
}
return false;
}
}

View File

@ -522,7 +522,7 @@ final class Graph
$files = [];
foreach ($baseline['results'] as $result) {
if (! self::shouldRerun($result['status'])) {
if (! $this->shouldRerun($result['status'])) {
continue;
}
@ -549,7 +549,7 @@ final class Graph
$baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) {
if (! self::shouldRerun($result['status'])) {
if (! $this->shouldRerun($result['status'])) {
continue;
}
@ -563,14 +563,20 @@ final class Graph
return false;
}
private static function shouldRerun(int $status): bool
private function shouldRerun(int $status): bool
{
$testStatus = TestStatus::from($status);
if ($testStatus->isFailure()) {
return true;
}
if ($testStatus->isError()) {
return true;
}
if ($testStatus->isIncomplete()) {
return true;
}
return $testStatus->isFailure()
|| $testStatus->isError()
|| $testStatus->isIncomplete()
|| $testStatus->isRisky();
return $testStatus->isRisky();
}
/**

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\TestSuite;
use ReflectionClass;
@ -33,21 +32,6 @@ final class Recorder
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
/** @var array<string, list<string>> */
private array $fileToClassNames = [];
/** @var array<string, true> */
private array $indexedClassNames = [];
/** @var array<string, list<string>> */
private array $classDependencyCache = [];
/** @var array<string, list<string>> */
private array $testImportFileCache = [];
/** @var array<string, true> */
private array $includedFilesAtTestStart = [];
private bool $active = false;
private bool $driverChecked = false;
@ -89,13 +73,6 @@ final class Recorder
return $this->driverAvailable;
}
public function driver(): string
{
$this->driverAvailable();
return $this->driver;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active || ! $this->driverAvailable()) {
@ -113,15 +90,11 @@ final class Recorder
}
$this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
// $this->linkAncestorFiles($className);
// $this->linkImportedFiles($file);
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
@ -166,19 +139,7 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
foreach (AutoloadEdges::newProjectFiles(
$this->includedFilesAtTestStart,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
$this->currentTestFile,
) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// $this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
}
public function linkSource(string $sourceFile): void
@ -198,295 +159,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
/** @param iterable<int, string> $sourceFiles */
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
return;
}
if ($testFile === '') {
return;
}
foreach ($sourceFiles as $sourceFile) {
if ($sourceFile === '') {
continue;
}
$this->perTestFiles[$testFile][$sourceFile] = true;
}
}
/** @param array<int, string> $coveredFiles */
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
return;
}
$this->refreshClassMap();
foreach ($coveredFiles as $coveredFile) {
if (! isset($this->fileToClassNames[$coveredFile])) {
continue;
}
foreach ($this->fileToClassNames[$coveredFile] as $name) {
foreach ($this->classDependencies($name) as $depFile) {
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
}
}
}
}
private function refreshClassMap(): void
{
$names = array_merge(
get_declared_classes(),
get_declared_interfaces(),
get_declared_traits(),
);
foreach ($names as $name) {
if (isset($this->indexedClassNames[$name])) {
continue;
}
$this->indexedClassNames[$name] = true;
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
continue;
}
$reflection = new ReflectionClass($name);
if ($reflection->isInternal()) {
continue;
}
$file = $reflection->getFileName();
if (! is_string($file)) {
continue;
}
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
continue;
}
$this->fileToClassNames[$file][] = $name;
}
}
/** @return list<string> */
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
return $this->classDependencyCache[$className];
}
if (! class_exists($className, false)
&& ! interface_exists($className, false)
&& ! trait_exists($className, false)) {
return $this->classDependencyCache[$className] = [];
}
$reflection = new ReflectionClass($className);
$files = [];
$linkSymbol = static function (string $name) use (&$files): void {
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
return;
}
$r = new ReflectionClass($name);
if ($r->isInternal()) {
return;
}
$f = $r->getFileName();
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
return;
}
$files[$f] = true;
};
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $reflection->getParentClass();
while ($parent !== false && ! $parent->isInternal()) {
$f = $parent->getFileName();
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$f] = true;
}
foreach ($parent->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $parent->getParentClass();
}
return $this->classDependencyCache[$className] = array_keys($files);
}
private function linkAncestorFiles(string $className): void
{
if (! class_exists($className, false)) {
return;
}
$reflection = new ReflectionClass($className);
$parent = $reflection->getParentClass();
while ($parent !== false) {
if ($parent->isInternal()) {
break;
}
$file = $parent->getFileName();
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
}
$parent = $parent->getParentClass();
}
}
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
return;
}
foreach ($this->importedFilesFor($testFile) as $file) {
$this->perTestFiles[$this->currentTestFile][$file] = true;
}
}
/**
* @return list<string>
*/
private function importedFilesFor(string $testFile): array
{
if (array_key_exists($testFile, $this->testImportFileCache)) {
return $this->testImportFileCache[$testFile];
}
$source = @file_get_contents($testFile);
if ($source === false) {
return $this->testImportFileCache[$testFile] = [];
}
$files = [];
foreach ($this->importedClassNames($source) as $className) {
$file = $this->findAutoloadFile($className);
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$file] = true;
}
}
return $this->testImportFileCache[$testFile] = array_keys($files);
}
/**
* @return list<string>
*/
private function importedClassNames(string $source): array
{
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
$classes = [];
foreach ($matches[1] as $import) {
$import = trim($import);
if ($import === '') {
continue;
}
$open = strpos($import, '{');
$close = strrpos($import, '}');
if ($open !== false && $close !== false && $close > $open) {
$prefix = trim(trim(substr($import, 0, $open)), '\\');
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
foreach ($items as $item) {
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
if ($class !== null) {
$classes[$class] = true;
}
}
continue;
}
$class = $this->normaliseImportedClass($import);
if ($class !== null) {
$classes[$class] = true;
}
}
return array_keys($classes);
}
private function normaliseImportedClass(string $import): ?string
{
$import = trim(trim($import), '\\');
if ($import === '') {
return null;
}
$parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false) {
return null;
}
$class = trim(trim($parts[0]), '\\');
return $class === '' ? null : $class;
}
private function findAutoloadFile(string $className): ?string
{
foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader)) {
continue;
}
if (! is_object($loader[0])) {
continue;
}
if (! method_exists($loader[0], 'findFile')) {
continue;
}
/** @var mixed $file */
$file = $loader[0]->findFile($className);
if (is_string($file) && $file !== '') {
$real = @realpath($file);
return $real === false ? $file : $real;
}
}
return null;
}
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
@ -691,9 +363,6 @@ final class Recorder
$this->perTestUsesDatabase = [];
$this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->fileToClassNames = [];
$this->indexedClassNames = [];
$this->classDependencyCache = [];
$this->sourceScope = null;
$this->active = false;
}

View File

@ -35,6 +35,8 @@ final class WatchPatterns
private bool $filtered = false;
private bool $baselined = false;
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
@ -156,12 +158,23 @@ final class WatchPatterns
return $this->filtered;
}
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void
{
$this->patterns = [];
$this->enabled = false;
$this->locally = false;
$this->filtered = false;
$this->baselined = false;
}
private function globMatches(string $pattern, string $file): bool

View File

@ -45,6 +45,9 @@ final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscrib
Panic::with(new TiaRequiresPestTests($className, $test->file()));
}
/**
* @param class-string $className
*/
private function usesTestableTrait(string $className): bool
{
$reflection = new ReflectionClass($className);

View File

@ -11,6 +11,9 @@ use Fidry\CpuCoreCounter\CpuCoreCounter;
*/
final class Cpu
{
/**
* @param int<1, max> $fallback
*/
public static function cores(int $fallback = 4): int
{
return (new CpuCoreCounter)->getCountWithFallback($fallback);