This commit is contained in:
nuno maduro
2026-04-27 11:15:59 +01:00
parent 48357c6f30
commit e457eb0e9c
4 changed files with 125 additions and 57 deletions

View File

@ -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';
}
}

View File

@ -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`

View File

@ -106,31 +106,56 @@ final class TableExtractor
/**
* @return list<string> 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 <name>` 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 = [];
// 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*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
foreach ($matches[1] as $i => $primary) {
// Group 1 always captures at least one char per the regex.
$tables[strtolower($primary)] = true;
// Group 2 (`Schema::rename('old', 'new')`) is optional and
// absent from non-rename matches.
$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;
}
}
}
$out = array_keys($tables);
sort($out);

View File

@ -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],