From f355b99bbf7eef3123a30d8bb8c9ab45fe00c662 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 29 Apr 2026 22:59:56 +0100 Subject: [PATCH] wip --- src/Plugins/Tia/Fingerprint.php | 61 +++++++++++++++++- src/Plugins/Tia/Graph.php | 107 +++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 470e99c1..0328918b 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -79,7 +79,11 @@ final readonly class Fingerprint // composer.lock hash a behavioural subset — description, // keywords, scripts, authors, install timestamps, dist // URLs etc. no longer drift the structural fingerprint. - private const int SCHEMA_VERSION = 12; + // v13: Environment files (`.env`, `.env.testing`, local variants) + // are included in the environmental bucket. They are commonly + // git-ignored, so watch patterns alone cannot reliably notice + // edits; a drift drops cached results and re-executes the suite. + private const int SCHEMA_VERSION = 13; /** * @return array{ @@ -128,6 +132,7 @@ final readonly class Fingerprint // the patch rarely changes anything test-visible. 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'extensions' => self::extensionsFingerprint($projectRoot), + 'env_files' => self::envFilesHash($projectRoot), ], ]; } @@ -289,6 +294,60 @@ final readonly class Fingerprint return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); } + /** + * Hashes environment files that can globally alter app boot behaviour. + * These files are often git-ignored, so they cannot rely on changed-file + * detection. The environmental bucket keeps graph edges while forcing all + * cached results to refresh after an env edit. + */ + private static function envFilesHash(string $projectRoot): ?string + { + $paths = [ + $projectRoot.'/.env', + $projectRoot.'/.env.testing', + $projectRoot.'/.env.local', + ]; + + $localVariants = glob($projectRoot.'/.env.*.local'); + + if (is_array($localVariants)) { + foreach ($localVariants as $path) { + $paths[] = $path; + } + } + + $parts = []; + $seen = []; + + foreach ($paths as $path) { + if (isset($seen[$path])) { + continue; + } + + $seen[$path] = true; + + if (! is_file($path)) { + continue; + } + + $contents = @file_get_contents($path); + + if ($contents === false) { + continue; + } + + $parts[] = basename($path).':'.hash('xxh128', $contents); + } + + if ($parts === []) { + return null; + } + + sort($parts); + + return hash('xxh128', implode("\n", $parts)); + } + /** * Behavioural subset of `composer.json`. Keeps the keys that * actually move test outcomes (autoload, require, extra, diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index fa81e962..9b119c92 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; use Pest\Support\Container; +use Pest\TestSuite; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestStatus\TestStatus; use Symfony\Component\Console\Output\OutputInterface; @@ -119,6 +121,14 @@ final class Graph */ private readonly string $projectRoot; + /** + * Cached project-relative test files that contain at least one test in the + * `arch` group. + * + * @var array|null + */ + private ?array $archTestFiles = null; + public function __construct(string $projectRoot) { $real = @realpath($projectRoot); @@ -420,7 +430,8 @@ final class Graph // Architecture tests inspect source structure by namespace / path rather // than by executing the inspected files. A new enum/class can therefore // fail an Arch expectation without ever producing a coverage edge. Keep - // this fallback narrow: only known Arch test files run, not the suite. + // this fallback narrow: only tests in Pest's `arch` group run, not the + // suite. if ($sourcePhpChanged) { foreach (array_keys($this->edges) as $testFile) { if ($this->isArchTestFile($testFile)) { @@ -910,6 +921,7 @@ final class Graph private function isProjectSourcePhp(string $rel): bool { return str_ends_with($rel, '.php') + && ! $this->isBladePath($rel) && ! str_starts_with($rel, 'tests/') && ! str_starts_with($rel, 'vendor/') && ! str_starts_with($rel, 'storage/framework/') @@ -918,8 +930,97 @@ final class Graph private function isArchTestFile(string $rel): bool { - return str_ends_with($rel, '.php') - && (str_contains($rel, '/Arch/') || str_ends_with($rel, '/ArchTest.php')); + return isset($this->archTestFiles()[$rel]); + } + + /** + * @return array + */ + private function archTestFiles(): array + { + if ($this->archTestFiles !== null) { + return $this->archTestFiles; + } + + $this->archTestFiles = []; + $repo = TestSuite::getInstance()->tests; + + foreach ($repo->getFilenames() as $filename) { + $factory = $repo->get($filename); + + if ($factory === null) { + continue; + } + + foreach ($factory->methods as $method) { + if (! $this->methodHasGroup($method, 'arch')) { + continue; + } + + $rel = $this->relative($filename); + + if ($rel !== null) { + $this->archTestFiles[$rel] = true; + } + + break; + } + } + + foreach (array_keys($this->edges) as $testFile) { + if (isset($this->archTestFiles[$testFile])) { + continue; + } + if ($this->testSourceDeclaresArchGroup($testFile)) { + $this->archTestFiles[$testFile] = true; + } + } + + return $this->archTestFiles; + } + + private function methodHasGroup(object $method, string $group): bool + { + if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) { + return true; + } + + if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) { + return false; + } + + foreach ($method->attributes as $attribute) { + if (! is_object($attribute)) { + continue; + } + if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) { + continue; + } + if (! property_exists($attribute, 'arguments')) { + continue; + } + + foreach ($attribute->arguments as $argument) { + if ($argument === $group) { + return true; + } + } + } + + return false; + } + + private function testSourceDeclaresArchGroup(string $rel): bool + { + $source = @file_get_contents($this->projectRoot.'/'.$rel); + + if ($source === false) { + return false; + } + + return preg_match('/\barch\s*\(/', $source) === 1 + || preg_match('/->\s*group\s*\(\s*[\'\"]arch[\'\"]/', $source) === 1 + || preg_match('/#\[\s*(?:\\\\)?(?:PHPUnit\\\\Framework\\\\Attributes\\\\)?Group\s*\(\s*[\'\"]arch[\'\"]/', $source) === 1; } private function isBladePath(string $rel): bool