From e457eb0e9c794bbbbe36cc31c1bbf0c6f0eebfbe Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 27 Apr 2026 11:15:59 +0100 Subject: [PATCH] wip --- src/Plugins/Tia/Fingerprint.php | 68 +++++++++++---------------- src/Plugins/Tia/Recorder.php | 47 ++++++++++++++++++ src/Plugins/Tia/TableExtractor.php | 59 ++++++++++++++++------- src/Plugins/Tia/WatchDefaults/Php.php | 8 ++++ 4 files changed, 125 insertions(+), 57 deletions(-) diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 048fd782..312bcdb9 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -9,15 +9,22 @@ namespace Pest\Plugins\Tia; * or its recorded results stale. The fingerprint is split into two buckets: * * - **structural** — describes what the graph's *edges* were recorded - * against. If any of these drift (`composer.lock`, `tests/Pest.php`, - * Pest's factory codegen, etc.) the edges themselves are potentially - * wrong and the graph must rebuild from scratch. + * against. If any of these drift (`composer.lock`, `composer.json`, + * `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the + * edges themselves are potentially wrong and the graph must rebuild + * from scratch. `tests/TestCase.php` and `tests/Pest.php` are + * intentionally NOT here — those are handled by per-test ancestor + * linking (`Recorder::linkAncestorFiles`) and the Php watch pattern + * respectively, which give precise invalidation rather than a wholesale + * rebuild. * - **environmental** — describes the *runtime* the results were captured - * on (PHP minor, extension set, Pest version). Drift here means the - * edges are still trustworthy, but the cached per-test results (pass/ - * fail/time) may not reproduce on this machine. Tia's handler drops the - * branch's results + coverage cache and re-runs to freshen them, rather - * than re-recording from scratch. + * on (PHP minor, extension set). Drift here means the edges are still + * trustworthy, but the cached per-test results (pass/fail/time) may + * not reproduce on this machine. Tia's handler drops the branch's + * results + coverage cache and re-runs to freshen them, rather than + * re-recording from scratch. Pest's own version is intentionally NOT + * here — `composer.lock`'s structural hash already moves whenever the + * installed Pest version changes. * * Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and * rebuilt on first load; the schema bump in the structural bucket takes @@ -60,7 +67,13 @@ final readonly class Fingerprint // Vite config change reshapes the module dependency graph // that `JsModuleGraph` records; without a graph rebuild // the stored `$jsFileToComponents` map silently goes stale. - private const int SCHEMA_VERSION = 10; + // v11: `composer.json` added (autoload-dev / extra discovery + // changes). `tests/TestCase.php` and `tests/Pest.php` are + // intentionally NOT fingerprinted — they're handled by the + // watch pattern + `Recorder::linkAncestorFiles` reflection + // walk, which gives precise per-test invalidation rather + // than a wholesale rebuild that trashes the entire graph. + private const int SCHEMA_VERSION = 11; /** * @return array{ @@ -76,7 +89,6 @@ final readonly class Fingerprint 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), - 'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'), // Pest's generated classes bake the code-generation logic // in — if TestCaseFactory changes (new attribute, different // method signature, etc.) every previously-recorded edge is @@ -90,6 +102,12 @@ final readonly class Fingerprint // the config drifts without a rebuild, the stored // `$jsFileToComponents` map is silently stale. 'vite_config' => self::viteConfigHash($projectRoot), + // `composer.json` carries `autoload-dev`, `extra.laravel` + // package discovery, etc. — any change reshapes which + // classes Pest can resolve at boot. Hashing the whole + // file is over-conservative (cosmetic edits force + // rebuild) but cheap, and over-rebuild is always safe. + 'composer_json' => self::hashIfExists($projectRoot.'/composer.json'), ], 'environmental' => [ // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch @@ -97,7 +115,6 @@ final readonly class Fingerprint // the patch rarely changes anything test-visible. 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'extensions' => self::extensionsFingerprint($projectRoot), - 'pest' => self::readPestVersion($projectRoot), ], ]; } @@ -308,33 +325,4 @@ final readonly class Fingerprint return array_values(array_unique($extensions)); } - - private static function readPestVersion(string $projectRoot): string - { - $installed = $projectRoot.'/vendor/composer/installed.json'; - - if (! is_file($installed)) { - return 'unknown'; - } - - $raw = @file_get_contents($installed); - - if ($raw === false) { - return 'unknown'; - } - - $data = json_decode($raw, true); - - if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) { - return 'unknown'; - } - - foreach ($data['packages'] as $package) { - if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') { - return (string) ($package['version'] ?? 'unknown'); - } - } - - return 'unknown'; - } } diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index d6b65436..e46b99a8 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -128,6 +128,19 @@ final class Recorder $this->currentTestFile = $file; + // Walk the parent-class chain and link each ancestor's defining + // file as a source dependency of this test. Captures the common + // `tests/TestCase.php` case (where the user's base may be + // trait-only and have no executable lines for the coverage + // driver to pick up), and any deeper hierarchy. Vendor parents + // are skipped — those are pinned by `composer.lock` and don't + // need per-test edges. Same idea applies to traits used by the + // ancestors: a trait's body executes when the test method + // calls into it, so coverage already captures it; we only need + // the explicit walk for ancestors whose own bodies might be + // empty. + $this->linkAncestorFiles($className); + if ($this->driver === 'pcov') { \pcov\clear(); \pcov\start(); @@ -191,6 +204,40 @@ final class Recorder $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } + /** + * Records every project-local ancestor class's defining file as a + * source dependency of the currently-running test. PCOV / Xdebug + * record *executable lines* — a base class whose body is just + * `class TestCase extends BaseTestCase { use CreatesApplication; }` + * has no executable bytecode of its own, so the driver doesn't + * emit a line for it and it never enters the graph through the + * usual coverage path. This walk fills that gap by asking + * reflection for each parent's file and linking it explicitly. + */ + 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(); + } + } + /** * Records that the currently-running test queried `$table`. Called * by `TableTracker` for every DML statement Laravel's `DB::listen` diff --git a/src/Plugins/Tia/TableExtractor.php b/src/Plugins/Tia/TableExtractor.php index 94c3a94d..da67a1ab 100644 --- a/src/Plugins/Tia/TableExtractor.php +++ b/src/Plugins/Tia/TableExtractor.php @@ -106,29 +106,54 @@ final class TableExtractor /** * @return list Table names referenced by `Schema::` calls - * in the given migration file contents. Empty - * when nothing matches — callers treat that - * as "fall back to the broad watch pattern". + * OR raw DDL statements in the given migration + * file contents. Empty when nothing matches — + * callers treat that as "fall back to the + * broad watch pattern". + * + * Two passes: + * 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename` + * captures the conventional Laravel migration shape. + * 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP / + * TRUNCATE / RENAME TABLE ` patterns inside string + * literals (i.e. `DB::statement('CREATE TABLE …')`, + * `DB::unprepared('ALTER TABLE …')`). False positives possible + * if the same syntax appears in a comment or unrelated string, + * but over-attribution is correctness-safe. */ public static function fromMigrationSource(string $php): array { - $pattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/'; - - if (preg_match_all($pattern, $php, $matches) === false) { - return []; - } - $tables = []; - foreach ($matches[1] as $i => $primary) { - // Group 1 always captures at least one char per the regex. - $tables[strtolower($primary)] = true; + // Pass 1: Schema:: calls. `dropColumn` (singular) covers + // `Schema::table('users', fn ($t) => $t->dropColumn('foo'))` + // — the closure body's column op is on Blueprint, but the + // outer `Schema::table('users', …)` is what we capture here. + $schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/'; - // Group 2 (`Schema::rename('old', 'new')`) is optional and - // absent from non-rename matches. - $secondary = $matches[2][$i] ?? ''; - if ($secondary !== '') { - $tables[strtolower($secondary)] = true; + if (preg_match_all($schemaPattern, $php, $matches) !== false) { + foreach ($matches[1] as $i => $primary) { + $tables[strtolower($primary)] = true; + + $secondary = $matches[2][$i] ?? ''; + if ($secondary !== '') { + $tables[strtolower($secondary)] = true; + } + } + } + + // Pass 2: raw DDL fallback. Matches the table name following + // `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres' + // `IF EXISTS` / `IF NOT EXISTS` variants), with optional + // ANSI / MySQL / SQL Server quoting. + $ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i'; + + if (preg_match_all($ddlPattern, $php, $matches) !== false) { + foreach ($matches[1] as $primary) { + $lower = strtolower($primary); + if (! self::isSchemaMeta($lower)) { + $tables[$lower] = true; + } } } diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php index bc6f0dc2..d8775fc5 100644 --- a/src/Plugins/Tia/WatchDefaults/Php.php +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -43,6 +43,14 @@ final readonly class Php implements WatchDefault // tracked by the coverage driver. 'phpunit.xml.dist' => [$testPath], + // `tests/Pest.php` is loaded once per suite (during BootFiles) + // so its `pest()->extend()`, `expect()->extend()`, helpers, + // etc. execute outside the per-test coverage window — no + // edge captures it. Watch-pattern broadcast triggers a + // replay of every test (results refresh) without a full + // record-mode graph rebuild. + $testPath.'/Pest.php' => [$testPath], + // Test fixtures — JSON, CSV, XML, TXT data files consumed by // assertions. A fixture change can flip a test result. $testPath.'/Fixtures/**/*.json' => [$testPath],