Compare commits

...

6 Commits

Author SHA1 Message Date
81bfdbf8fe wip 2026-04-27 13:16:05 +01:00
f45cbf43c5 wip 2026-04-27 13:11:48 +01:00
b9088d23fb wip 2026-04-27 13:03:07 +01:00
7250185423 wip 2026-04-27 12:22:05 +01:00
e457eb0e9c wip 2026-04-27 11:15:59 +01:00
48357c6f30 wip 2026-04-27 10:30:08 +01:00
10 changed files with 1034 additions and 120 deletions

View File

@ -17,11 +17,13 @@ use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\JsModuleGraph; use Pest\Plugins\Tia\JsModuleGraph;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
use Throwable; use Throwable;
/** /**
@ -87,7 +89,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* flag forces an immediate retry (e.g. right after publishing a * flag forces an immediate retry (e.g. right after publishing a
* baseline from CI for the first time). * baseline from CI for the first time).
*/ */
private const string REFETCH_OPTION = '--tia-refetch'; private const string REFETCH_OPTION = '--refetch';
/** /**
* State keys under which TIA persists its blobs. Kept here as constants * State keys under which TIA persists its blobs. Kept here as constants
@ -104,6 +106,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
/**
* Sentinel dropped by a recording worker that found no usable
* coverage driver in its own process. Workers can have a different
* PHP env from the parent (Herd profile, custom ini scandir, CI
* runners that strip extensions), so the parent's driver check
* doesn't catch this. The parent reads these at end-of-run and
* surfaces a single warning so partial coverage loss isn't
* silent.
*/
private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-';
/** /**
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage` * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
* run. Stored as bytes so the backend stays JSON/file-agnostic — the * run. Stored as bytes so the backend stays JSON/file-agnostic — the
@ -122,7 +135,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds * Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
* `{"until": <unix>}` — subsequent runs within the window skip the * `{"until": <unix>}` — subsequent runs within the window skip the
* fetch attempt (and its `gh run list` network hop) until the * fetch attempt (and its `gh run list` network hop) until the
* cooldown expires or the user passes `--tia-refetch`. * cooldown expires or the user passes `--refetch`.
*/ */
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json'; public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
@ -223,11 +236,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private bool $recordingActive = false; private bool $recordingActive = false;
/** /**
* True when `--tia-refetch` is in the current argv — `BaselineSync` * True when `--refetch` is in the current argv — `BaselineSync`
* uses it to bypass the post-failure fetch cooldown. * uses it to bypass the post-failure fetch cooldown.
*/ */
private bool $forceRefetch = false; private bool $forceRefetch = false;
/**
* True when `--fresh` is in the current argv — record-mode paths
* use it to gate `Graph::pruneMissingTests()`. On a partial record
* (default `--tia` after a branch switch, etc.) the working tree may
* not contain every test the shared graph knows about, so pruning
* would silently delete edges for tests that exist on other
* branches. `--fresh` rebuilds from scratch anyway, so pruning
* there is both safe and useful for cleaning up stale entries.
*/
private bool $freshRebuild = false;
public function __construct( public function __construct(
private readonly OutputInterface $output, private readonly OutputInterface $output,
private readonly Recorder $recorder, private readonly Recorder $recorder,
@ -347,6 +371,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// silently ignore it here and let whatever else consumes it // silently ignore it here and let whatever else consumes it
// handle it. The flag isn't popped in that branch. // handle it. The flag isn't popped in that branch.
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild;
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
return $arguments; return $arguments;
@ -415,6 +440,24 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$perTestTables = $recorder->perTestTables(); $perTestTables = $recorder->perTestTables();
$perTestInertia = $recorder->perTestInertiaComponents(); $perTestInertia = $recorder->perTestInertiaComponents();
$perTestUsesDatabase = $recorder->perTestUsesDatabase();
// Tests that use Laravel's DB-resetting traits (`RefreshDatabase`,
// `DatabaseMigrations`, `DatabaseTransactions`) but recorded zero
// queries during their body — typical seeded-fixture / attribute-
// assertion tests — would otherwise have empty `$testTables` and
// get silently skipped on migration changes. The migrations and
// seed DML run during `parent::setUp()` before `TableTracker`
// arms, so we can't capture them. Instead, conservatively union
// the project-wide migration table set into those tests so any
// schema change re-runs them.
if ($perTestUsesDatabase !== []) {
$perTestTables = $this->augmentDatabaseTestTables(
$perTestTables,
$perTestUsesDatabase,
$projectRoot,
);
}
if (Parallel::isWorker()) { if (Parallel::isWorker()) {
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia); $this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
@ -443,7 +486,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceTestTables($perTestTables); $graph->replaceTestTables($perTestTables);
$graph->replaceTestInertiaComponents($perTestInertia); $graph->replaceTestInertiaComponents($perTestInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
$graph->pruneMissingTests();
// Pruning checks the local filesystem for each known test file —
// on a partial record (no `--fresh`) the current checkout may
// legitimately be missing tests that exist on other branches
// sharing this graph, so pruning would silently delete their
// edges. Stale entries for genuinely-deleted tests are harmless
// (test discovery never finds the file) and get cleaned up on
// the next `--fresh` rebuild.
if ($this->freshRebuild) {
$graph->pruneMissingTests();
}
// Fold in the results collected during this same record run. The // Fold in the results collected during this same record run. The
// `AddsOutput` pass that runs `snapshotTestResults` fires *before* // `AddsOutput` pass that runs `snapshotTestResults` fires *before*
@ -482,6 +535,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode; return $exitCode;
} }
$this->reportMissingWorkerDrivers();
// After a successful replay run, advance the recorded SHA to HEAD // After a successful replay run, advance the recorded SHA to HEAD
// so the next run only diffs against what changed since NOW, not // so the next run only diffs against what changed since NOW, not
// since the original recording. Without this, re-running `--tia` // since the original recording. Without this, re-running `--tia`
@ -611,7 +666,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceTestTables($finalisedTables); $graph->replaceTestTables($finalisedTables);
$graph->replaceTestInertiaComponents($finalisedInertia); $graph->replaceTestInertiaComponents($finalisedInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
$graph->pruneMissingTests();
// See `terminate()` — same rationale: pruning by current
// working-tree presence would silently drop edges for tests
// owned by other branches sharing this graph. Only safe on
// `--fresh` rebuilds.
if ($this->freshRebuild) {
$graph->pruneMissingTests();
}
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.'); $this->output->writeln(' <fg=red>TIA</> failed to write graph.');
@ -658,9 +720,50 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$stored = $graph->fingerprint(); $stored = $graph->fingerprint();
if (! Fingerprint::structuralMatches($stored, $current)) { if (! Fingerprint::structuralMatches($stored, $current)) {
$this->output->writeln( $drift = Fingerprint::structuralDrift($stored, $current);
' <fg=yellow>TIA</> graph structure outdated — rebuilding.',
); $this->output->writeln(sprintf(
' <fg=yellow>TIA</> graph structure outdated (%s).',
$this->formatStructuralDrift($drift),
));
// For composer.lock specifically, surface the actual
// package-version deltas. Saves the user a `git diff
// composer.lock | grep -E "name|version"` round-trip when
// a routine `composer update` invalidates the graph.
if (in_array('composer_lock', $drift, true)) {
$branchSha = $graph->recordedAtSha($this->branch);
if ($branchSha !== null) {
$summary = $this->composerLockDelta(
TestSuite::getInstance()->rootPath,
$branchSha,
);
if ($summary !== '') {
$this->output->writeln(' <fg=gray>'.$summary.'</>');
}
}
}
// Try the remote baseline before paying for a local
// rebuild. CI runs the baseline workflow against every
// push to main, so the most common cause of structural
// drift (`composer update` landed on main, you pulled it,
// your branch hasn't diverged yet) is recoverable in
// ~530s of network instead of minutes of recording.
//
// Revalidation is the safety: even if the fetch succeeds,
// we only adopt the result when its stored fingerprint
// structurally matches the *current* one. A stale CI
// baseline (workflow hasn't run since the drift) gets
// dropped and we fall through to the local rebuild path.
$rebuilt = $this->tryRemoteBaselineForDrift($current);
if ($rebuilt instanceof Graph) {
return $this->reconcileFingerprint($rebuilt, $current);
}
$this->output->writeln(' <fg=yellow>TIA</> rebuilding graph from scratch.');
$this->state->delete(self::KEY_GRAPH); $this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE); $this->state->delete(self::KEY_COVERAGE_CACHE);
@ -789,9 +892,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recorder = $this->recorder; $recorder = $this->recorder;
if (! $recorder->driverAvailable()) { if (! $recorder->driverAvailable()) {
// Driver availability is per-process. If the driver is missing // Worker PHP can differ from the parent (Herd profile, custom
// here, silently skip — the parent has already warned during // `php.ini` scan dir, stripped CI runner). Drop a sentinel so
// its own boot. // the parent surfaces a single warning at end-of-run instead
// of letting the missing per-test edges and results pass
// unnoticed.
$this->state->write(
self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json',
'{}',
);
return $arguments; return $arguments;
} }
@ -1027,6 +1137,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
} }
/**
* Reads per-worker "no driver available" sentinels and surfaces a
* single warning to the parent's terminal. Self-clears so the
* sentinel doesn't leak into the next run. No-op when every worker
* had a usable coverage driver.
*/
private function reportMissingWorkerDrivers(): void
{
$keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX);
if ($keys === []) {
return;
}
foreach ($keys as $key) {
$this->state->delete($key);
}
$this->output->writeln(sprintf(
' <fg=yellow>TIA</> %d worker(s) had no coverage driver — their per-test edges and results were dropped. '
.'Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and rerun.',
count($keys),
));
}
private function purgeWorkerPartials(): void private function purgeWorkerPartials(): void
{ {
foreach ($this->collectWorkerEdgesPartials() as $key) { foreach ($this->collectWorkerEdgesPartials() as $key) {
@ -1356,4 +1491,237 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $coverage->coverage === true; return $coverage->coverage === true;
} }
/**
* Attempts to short-circuit a structural-drift rebuild by fetching
* a fresh CI-recorded baseline. Returns the loaded `Graph` only if
* the fetched payload structurally matches the *current* fingerprint
* — i.e., CI has already recorded against the new shape and we can
* safely use those edges. Any other outcome (no GitHub remote, fetch
* cooldown, no successful CI run, fetched-graph-still-drifts) → null,
* caller falls back to local rebuild.
*
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/
private function tryRemoteBaselineForDrift(array $current): ?Graph
{
$projectRoot = TestSuite::getInstance()->rootPath;
if (! $this->baselineSync->fetchIfAvailable($projectRoot, false)) {
return null;
}
$fetched = $this->loadGraph($projectRoot);
if (! $fetched instanceof Graph) {
return null;
}
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
$this->output->writeln(
' <fg=yellow>TIA</> fetched baseline still drifts — discarding.',
);
return null;
}
$this->output->writeln(
' <fg=green>TIA</> fetched baseline matches — skipping local rebuild.',
);
return $fetched;
}
/**
* Maps `Fingerprint::structuralDrift()` field names to a human
* label suitable for the `(reason)` part of the rebuild banner.
*
* @param list<string> $drift
*/
private function formatStructuralDrift(array $drift): string
{
static $labels = [
'composer_lock' => 'composer.lock',
'composer_json' => 'composer.json',
'phpunit_xml' => 'phpunit.xml',
'phpunit_xml_dist' => 'phpunit.xml.dist',
'vite_config' => 'vite.config',
'pest_factory' => 'Pest internals',
'pest_method_factory' => 'Pest internals',
];
$seen = [];
foreach ($drift as $key) {
$seen[$labels[$key] ?? $key] = true;
}
if ($seen === []) {
return 'unknown';
}
return implode(', ', array_keys($seen));
}
/**
* Diffs `composer.lock` between the recorded SHA and the current
* working tree, returns a one-line summary like:
*
* "laravel/framework 12.30 → 12.31, + pestphp/pest 4.7"
*
* Empty string when git is unavailable, the sha doesn't have the
* file, the file can't be parsed, or there are no version
* deltas (a content-hash-only edit, vendor URL change, etc.).
*/
private function composerLockDelta(string $projectRoot, string $sha): string
{
$current = @file_get_contents($projectRoot.'/composer.lock');
if ($current === false) {
return '';
}
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return '';
}
$oldVersions = $this->lockVersions($process->getOutput());
$newVersions = $this->lockVersions($current);
if ($oldVersions === [] && $newVersions === []) {
return '';
}
$changes = [];
foreach ($newVersions as $name => $version) {
if (! isset($oldVersions[$name])) {
$changes[] = '+ '.$name.' '.$version;
} elseif ($oldVersions[$name] !== $version) {
$changes[] = $name.' '.$oldVersions[$name].' → '.$version;
}
}
foreach ($oldVersions as $name => $version) {
if (! isset($newVersions[$name])) {
$changes[] = ' '.$name.' '.$version;
}
}
if ($changes === []) {
return '';
}
sort($changes);
// Cap at a sensible number — a wholesale `composer update`
// could list 50+ packages and bury the prompt.
$maxShown = 8;
if (count($changes) > $maxShown) {
$extra = count($changes) - $maxShown;
$changes = array_slice($changes, 0, $maxShown);
$changes[] = sprintf('… +%d more', $extra);
}
return implode(', ', $changes);
}
/**
* Unions the project's full migration-defined table set into every
* test that uses a Laravel DB-resetting trait. Captures the
* seeded-attribute case where `parent::setUp()` ran inserts before
* `TableTracker` armed and the test body issued no further queries
* — without this, those tests would have empty `$testTables` and
* be silently skipped on migration changes.
*
* Tests that DID record specific tables in their body keep those
* (the union is additive). The migration scan is cheap (one pass
* over `database/migrations/`) and only runs once per record.
*
* @param array<string, array<int, string>> $perTestTables
* @param array<string, true> $perTestUsesDatabase
* @return array<string, array<int, string>>
*/
private function augmentDatabaseTestTables(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array
{
$migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations';
if (! is_dir($migrationDir)) {
return $perTestTables;
}
$allTables = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($migrationDir, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $fileInfo) {
if (! $fileInfo->isFile()) {
continue;
}
if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) {
continue;
}
$content = @file_get_contents((string) $fileInfo->getPathname());
if ($content === false) {
continue;
}
foreach (TableExtractor::fromMigrationSource($content) as $table) {
$allTables[strtolower($table)] = true;
}
}
if ($allTables === []) {
return $perTestTables;
}
foreach (array_keys($perTestUsesDatabase) as $testFile) {
$existing = $perTestTables[$testFile] ?? [];
$merged = array_fill_keys($existing, true) + $allTables;
$names = array_keys($merged);
sort($names);
$perTestTables[$testFile] = $names;
}
return $perTestTables;
}
/**
* @return array<string, string> package name → version
*/
private function lockVersions(string $json): array
{
$data = json_decode($json, true);
if (! is_array($data)) {
return [];
}
$out = [];
foreach (['packages', 'packages-dev'] as $section) {
if (! isset($data[$section])) {
continue;
}
if (! is_array($data[$section])) {
continue;
}
foreach ($data[$section] as $package) {
if (! is_array($package)) {
continue;
}
$name = $package['name'] ?? null;
$version = $package['version'] ?? null;
if (is_string($name) && is_string($version)) {
$out[$name] = $version;
}
}
}
return $out;
}
} }

View File

@ -67,7 +67,7 @@ final readonly class BaselineSync
* Rationale: when the remote workflow hasn't published yet, every * Rationale: when the remote workflow hasn't published yet, every
* `pest --tia` invocation would otherwise re-hit `gh run list` and * `pest --tia` invocation would otherwise re-hit `gh run list` and
* re-print the publish instructions — noisy + slow. Back off for a * re-print the publish instructions — noisy + slow. Back off for a
* day, let the user override with `--tia-refetch`. * day, let the user override with `--refetch`.
*/ */
private const int FETCH_COOLDOWN_SECONDS = 86400; private const int FETCH_COOLDOWN_SECONDS = 86400;
@ -82,7 +82,7 @@ final readonly class BaselineSync
* landed; coverage is best-effort since plain `--tia` (no `--coverage`) * landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it. * never reads it.
* *
* `$force = true` (driven by `--tia-refetch`) ignores the post-failure * `$force = true` (driven by `--refetch`) ignores the post-failure
* cooldown so the user can retry on demand without waiting out the * cooldown so the user can retry on demand without waiting out the
* 24h window. * 24h window.
*/ */
@ -97,7 +97,7 @@ final readonly class BaselineSync
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. ' ' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
.'Override with <fg=cyan>--tia-refetch</>.', .'Override with <fg=cyan>--refetch</>.',
$this->formatDuration($remaining), $this->formatDuration($remaining),
)); ));
@ -367,6 +367,15 @@ YAML;
return $m[1]; return $m[1];
} }
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
// Some teams configure this explicitly to pin the SSH port; the
// colon-separated form above doesn't match. Mirrors the parser
// in `Storage::originIdentity` so the same remote produces the
// same project key for both storage and remote-fetch.
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
}
return null; return null;
} }

View File

@ -283,6 +283,11 @@ final readonly class ChangedFiles
'.phpunit.result.cache', '.phpunit.result.cache',
'vendor/', 'vendor/',
'node_modules/', 'node_modules/',
// Laravel regenerates these from manifest state
// (package.json, service providers) at boot — they're
// fully derived, not authored. Treating them as
// "changes" just flaps the diff noisily.
'bootstrap/cache/',
]; ];
foreach ($prefixes as $prefix) { foreach ($prefixes as $prefix) {

View File

@ -9,15 +9,22 @@ namespace Pest\Plugins\Tia;
* or its recorded results stale. The fingerprint is split into two buckets: * or its recorded results stale. The fingerprint is split into two buckets:
* *
* - **structural** — describes what the graph's *edges* were recorded * - **structural** — describes what the graph's *edges* were recorded
* against. If any of these drift (`composer.lock`, `tests/Pest.php`, * against. If any of these drift (`composer.lock`, `composer.json`,
* Pest's factory codegen, etc.) the edges themselves are potentially * `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the
* wrong and the graph must rebuild from scratch. * 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 * - **environmental** — describes the *runtime* the results were captured
* on (PHP minor, extension set, Pest version). Drift here means the * on (PHP minor, extension set). Drift here means the edges are still
* edges are still trustworthy, but the cached per-test results (pass/ * trustworthy, but the cached per-test results (pass/fail/time) may
* fail/time) may not reproduce on this machine. Tia's handler drops the * not reproduce on this machine. Tia's handler drops the branch's
* branch's results + coverage cache and re-runs to freshen them, rather * results + coverage cache and re-runs to freshen them, rather than
* than re-recording from scratch. * 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 * Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
* rebuilt on first load; the schema bump in the structural bucket takes * rebuilt on first load; the schema bump in the structural bucket takes
@ -60,7 +67,19 @@ final readonly class Fingerprint
// Vite config change reshapes the module dependency graph // Vite config change reshapes the module dependency graph
// that `JsModuleGraph` records; without a graph rebuild // that `JsModuleGraph` records; without a graph rebuild
// the stored `$jsFileToComponents` map silently goes stale. // 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.
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
// now hash via `ContentHash::of()` so cosmetic comment +
// whitespace edits don't fire rebuilds. composer.json and
// 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;
/** /**
* @return array{ * @return array{
@ -73,18 +92,35 @@ final readonly class Fingerprint
return [ return [
'structural' => [ 'structural' => [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), // `composer.lock` hashed against a *behavioural*
// subset (per-package version + reference + autoload +
// extra). Skips per-package install timestamps, dist
// URLs, support links, descriptions — none of which
// affect what code runs.
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), '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 // Pest's generated classes bake the code-generation logic
// in — if TestCaseFactory changes (new attribute, different // in — if TestCaseFactory changes (new attribute, different
// method signature, etc.) every previously-recorded edge is // method signature, etc.) every previously-recorded edge is
// stale. Hashing the factory sources makes path-repo / // stale. Hashing via `ContentHash::of()` so cosmetic edits
// dev-main installs automatically rebuild their graphs when // (comments, formatting) don't drift the fingerprint.
// Pest itself is edited. 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), // `vite.config.*` reshapes the module graph
// `JsModuleGraph` records at the next `--tia` run; if
// the config drifts without a rebuild, the stored
// `$jsFileToComponents` map is silently stale.
// `viteConfigHash` itself uses `ContentHash::of()` so
// a comment-only edit to vite.config doesn't rebuild.
'vite_config' => self::viteConfigHash($projectRoot),
// `composer.json` hashed against a behavioural subset:
// autoload(-dev), require(-dev), extra (Laravel
// package discovery), repositories, minimum-stability,
// and the platform / allow-plugins entries from
// `config`. Cosmetic fields (description, keywords,
// scripts, authors, funding, support) are excluded.
'composer_json' => self::composerJsonHash($projectRoot),
], ],
'environmental' => [ 'environmental' => [
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
@ -92,7 +128,6 @@ final readonly class Fingerprint
// the patch rarely changes anything test-visible. // the patch rarely changes anything test-visible.
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint($projectRoot), 'extensions' => self::extensionsFingerprint($projectRoot),
'pest' => self::readPestVersion($projectRoot),
], ],
]; ];
} }
@ -115,6 +150,45 @@ final readonly class Fingerprint
return $aStructural === $bStructural; return $aStructural === $bStructural;
} }
/**
* Returns the list of structural field names that drifted between
* the stored and current fingerprints. Empty list = no drift.
* Caller uses this to tell the user *why* the graph rebuilt — a
* generic "graph outdated" message leaves people staring at
* unrelated diffs.
*
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function structuralDrift(array $stored, array $current): array
{
$a = self::structuralOnly($stored);
$b = self::structuralOnly($current);
$drifts = [];
foreach ($a as $key => $value) {
if ($key === 'schema') {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === 'schema') {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
}
/** /**
* Returns a list of field names that drifted between the stored and * Returns a list of field names that drifted between the stored and
* current environmental fingerprints. Empty list = no drift. Caller * current environmental fingerprints. Empty list = no drift. Caller
@ -193,6 +267,206 @@ final readonly class Fingerprint
return $normalised; return $normalised;
} }
/**
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
* at the project root. Most projects have exactly one; we accept
* any of the five recognised extensions without assuming which
* the user picked. Returns null when no config file exists —
* treated as "no Vite project" by the matcher, no drift.
*/
private static function viteConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
/**
* Behavioural subset of `composer.json`. Keeps the keys that
* actually move test outcomes (autoload, require, extra,
* repositories, minimum-stability, platform / allow-plugins
* config) and drops cosmetic ones (description, keywords,
* scripts, authors, funding, homepage, support). Falls back to
* a raw hash on parse errors so any change still rebuilds.
*/
private static function composerJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
$relevantConfig = array_intersect_key($config, [
'platform' => true,
'allow-plugins' => true,
]);
$relevant = [
'autoload' => $data['autoload'] ?? null,
'autoload-dev' => $data['autoload-dev'] ?? null,
'require' => $data['require'] ?? null,
'require-dev' => $data['require-dev'] ?? null,
'extra' => $data['extra'] ?? null,
'repositories' => $data['repositories'] ?? null,
'minimum-stability' => $data['minimum-stability'] ?? null,
'prefer-stable' => $data['prefer-stable'] ?? null,
'config' => $relevantConfig === [] ? null : $relevantConfig,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
/**
* Behavioural subset of `composer.lock`. For every package in
* `packages` and `packages-dev`, keeps version + dist/source
* reference (commit SHA — catches dev-branch updates that don't
* bump the version string) + autoload(-dev) + extra (Laravel
* package discovery). Drops install timestamps, dist URLs,
* support links, descriptions, etc. — none of which change what
* code runs.
*/
private static function composerLockHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.lock';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$relevant = [
'platform' => $data['platform'] ?? null,
'platform-dev' => $data['platform-dev'] ?? null,
];
foreach (['packages', 'packages-dev'] as $section) {
if (! isset($data[$section])) {
continue;
}
if (! is_array($data[$section])) {
continue;
}
$packages = [];
foreach ($data[$section] as $package) {
if (! is_array($package)) {
continue;
}
$name = $package['name'] ?? null;
if (! is_string($name)) {
continue;
}
$packages[$name] = [
'version' => $package['version'] ?? null,
'reference' => self::lockReference($package),
'autoload' => $package['autoload'] ?? null,
'autoload-dev' => $package['autoload-dev'] ?? null,
'extra' => $package['extra'] ?? null,
];
}
ksort($packages);
$relevant[$section] = $packages;
}
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
/**
* @param array<string, mixed> $package
*/
private static function lockReference(array $package): ?string
{
$dist = is_array($package['dist'] ?? null) ? $package['dist'] : [];
$source = is_array($package['source'] ?? null) ? $package['source'] : [];
$reference = $dist['reference'] ?? $source['reference'] ?? null;
return is_string($reference) ? $reference : null;
}
/**
* Recursively sorts associative arrays by key so semantically
* equivalent JSON produces the same hash regardless of key
* ordering. Lists (numeric arrays) keep their order — they're
* meaningful in `repositories`, `autoload.files`, etc.
*/
private static function sortRecursively(mixed &$value): void
{
if (! is_array($value)) {
return;
}
$isAssoc = ! array_is_list($value);
if ($isAssoc) {
ksort($value);
}
foreach ($value as &$child) {
self::sortRecursively($child);
}
}
private static function contentHashOrNull(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = ContentHash::of($path);
return $hash === false ? null : $hash;
}
private static function hashIfExists(string $path): ?string private static function hashIfExists(string $path): ?string
{ {
if (! is_file($path)) { if (! is_file($path)) {
@ -281,33 +555,4 @@ final readonly class Fingerprint
return array_values(array_unique($extensions)); 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

@ -6,6 +6,7 @@ namespace Pest\Plugins\Tia;
use Pest\Support\Container; use Pest\Support\Container;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
/** /**
* File-level Test Impact Analysis graph. * File-level Test Impact Analysis graph.
@ -226,17 +227,18 @@ final class Graph
} }
} }
// Inertia page-component routing. When a Vue/React/Svelte page // Inertia page-component routing. When a page under
// under `resources/js/Pages/` changes, map it to the component // `resources/js/Pages/` changes, map it to the component name
// name Inertia would use (the path relative to `Pages/`, with // Inertia would use (the path relative to `Pages/`, extension
// the extension stripped) and intersect with the captured // stripped) and intersect with the captured component edges.
// component edges. Only invalidates tests that actually // Only invalidates tests that actually rendered the page.
// rendered the page. Pages with no captured edges (never // Pages with no captured edges (never rendered during record,
// rendered during record, brand-new on this branch) fall // brand-new on this branch) fall through to the watch-pattern
// through to the watch-pattern fallback via // fallback — safe over-run. Pages handled here are tracked in
// `$unknownPageComponents` — safe over-run. // `$preciselyHandledPages` so the watch broadcast and JS-dep
// lookup don't re-route them.
$changedComponents = []; $changedComponents = [];
$unknownPageComponents = []; $preciselyHandledPages = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
$component = $this->componentForInertiaPage($rel); $component = $this->componentForInertiaPage($rel);
@ -247,20 +249,6 @@ final class Graph
if ($this->anyTestUses($this->testInertiaComponents, $component)) { if ($this->anyTestUses($this->testInertiaComponents, $component)) {
$changedComponents[$component] = true; $changedComponents[$component] = true;
} else {
$unknownPageComponents[] = $rel;
}
}
// Pages whose component already resolved precisely via the
// direct Inertia edges path must not leak back through any
// broader mechanism (either the JS-dep lookup below, or the
// watch pattern further down).
$preciselyHandledPages = [];
foreach ($nonMigrationPaths as $rel) {
$component = $this->componentForInertiaPage($rel);
if ($component !== null && isset($changedComponents[$component])) {
$preciselyHandledPages[$rel] = true; $preciselyHandledPages[$rel] = true;
} }
} }
@ -297,6 +285,83 @@ final class Graph
} }
} }
// Orphan detection for NEW JS files. `$jsFileToComponents` is
// a record-time snapshot; files added since (a fresh Vue
// component, a new shared util, etc.) are absent from it.
// Today the broad watch pattern catches them — correct but
// pessimistic: a JS file that literally no page imports
// would still invalidate the entire browser dir.
//
// Fix: for each new JS file in the changed set, ask Vite
// (strict mode — no PHP fallback) which pages transitively
// import it. If none → orphan, suppress the broadcast. If
// some → precise union with their tests' components. The
// Node helper is the only resolver trustworthy enough to
// honour a *negative* answer (the PHP parser can silently
// miss custom aliases). When Node is unreachable we leave
// the files alone and let the watch pattern do its job.
$newJsFiles = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) {
continue;
}
if (isset($sharedFilesResolved[$rel])) {
continue;
}
if (isset($this->jsFileToComponents[$rel])) {
continue;
}
if (! str_starts_with($rel, 'resources/js/')) {
continue;
}
$newJsFiles[] = $rel;
}
if ($newJsFiles !== []) {
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) {
// Vite resolver was unavailable (Node missing, cold-start
// timeout, vite.config refused to load). Falling back to
// the broad watch pattern is the correct call, but
// doing so silently can make a slow replay feel
// inexplicable — surface a single line so the user
// knows precision was downgraded for these files.
$output = Container::getInstance()->get(OutputInterface::class);
if ($output instanceof OutputInterface) {
$output->writeln(sprintf(
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
));
}
} else {
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
// Vite itself says nothing imports this file.
// Safe to skip — mark handled so the watch
// pattern below doesn't re-broadcast it.
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
}
if ($changedComponents !== []) { if ($changedComponents !== []) {
foreach ($this->testInertiaComponents as $testFile => $components) { foreach ($this->testInertiaComponents as $testFile => $components) {
if (isset($affectedSet[$testFile])) { if (isset($affectedSet[$testFile])) {
@ -680,9 +745,13 @@ final class Graph
/** /**
* Replaces the whole JS dep map. Called at record time with the * Replaces the whole JS dep map. Called at record time with the
* output of `JsModuleGraph::build()`. Unlike the test-level * output of `JsModuleGraph::build()`. Empty input is treated as a
* replacements above this is a wholesale overwrite — the * resolver failure (Node missing, Vite refused to load, transient
* resolver produces the full graph on every run. * `npm install`) rather than a legitimate "no JS pages" signal —
* we keep the previous map. Stale entries for genuinely-deleted
* pages are harmless because deleted files never enter the
* changed set; over-broadcasting every JS edit through the watch
* pattern after a flaky Node run would be a real regression.
* *
* @param array<string, array<int, string>> $fileToComponents * @param array<string, array<int, string>> $fileToComponents
*/ */
@ -711,6 +780,10 @@ final class Graph
$out[$path] = $keys; $out[$path] = $keys;
} }
if ($out === []) {
return;
}
ksort($out); ksort($out);
$this->jsFileToComponents = $out; $this->jsFileToComponents = $out;
@ -779,7 +852,7 @@ final class Graph
$extension = substr($tail, $dot + 1); $extension = substr($tail, $dot + 1);
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte'], true)) { if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
return null; return null;
} }

View File

@ -51,6 +51,24 @@ final class JsModuleGraph
return JsImportParser::parse($projectRoot); return JsImportParser::parse($projectRoot);
} }
/**
* Strict variant — only runs the Node helper, never falls back to
* the PHP parser. Returns null when Node isn't available or Vite
* won't load.
*
* Used at replay time when we need to *trust a negative result*
* (i.e., "no page imports this file, so it's orphan, safe to
* skip"). The PHP fallback is conservative on positives but can
* miss imports that rely on custom aliases or plugins — negative
* results from it cannot be trusted for orphan pruning.
*
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
{
return self::tryNodeHelper($projectRoot);
}
/** /**
* True when the project looks like a Vite + Node project we can * True when the project looks like a Vite + Node project we can
* ask for a module graph. Gate for callers that want to skip the * ask for a module graph. Gate for callers that want to skip the

View File

@ -50,6 +50,18 @@ final class Recorder
*/ */
private array $perTestInertiaComponents = []; private array $perTestInertiaComponents = [];
/**
* Set of absolute test files whose class hierarchy uses one of
* Laravel's database-resetting traits (`RefreshDatabase`,
* `DatabaseMigrations`, `DatabaseTransactions`). Captured at
* `beginTest` so the finalize path can augment their table edges
* even when seeders / pre-test DML fired before `TableTracker`
* armed.
*
* @var array<string, true>
*/
private array $perTestUsesDatabase = [];
/** /**
* Cached class → test file resolution. * Cached class → test file resolution.
* *
@ -57,6 +69,13 @@ final class Recorder
*/ */
private array $classFileCache = []; private array $classFileCache = [];
/**
* Cached class → "uses Laravel DB trait" introspection result.
*
* @var array<string, bool>
*/
private array $classUsesDatabaseCache = [];
private bool $active = false; private bool $active = false;
private bool $driverChecked = false; private bool $driverChecked = false;
@ -81,21 +100,19 @@ final class Recorder
if (function_exists('pcov\\start')) { if (function_exists('pcov\\start')) {
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Xdebug is loaded. Probe whether coverage mode is active by // Xdebug 3+ exposes the active mode set via `xdebug_info`,
// attempting a start — it emits E_WARNING when the mode is off. // so we can ask directly instead of probing with a
// We capture the warning via a temporary error handler. // start/stop pair. The probe approach used to emit
$probeOk = true; // E_WARNING when coverage mode was off; with monitoring
set_error_handler(static function () use (&$probeOk): bool { // agents (Sentry, Bugsnag) hooked into the error
$probeOk = false; // handler stack that warning could be reported as a
// real error. `xdebug_info('mode')` is silent and
// returns the active modes as a list, so a presence
// check is enough.
$modes = \xdebug_info('mode');
return true; if (is_array($modes) && in_array('coverage', $modes, true)) {
});
\xdebug_start_code_coverage();
restore_error_handler();
if ($probeOk) {
\xdebug_stop_code_coverage(false);
$this->driver = 'xdebug'; $this->driver = 'xdebug';
$this->driverAvailable = true; $this->driverAvailable = true;
} }
@ -128,6 +145,23 @@ final class Recorder
$this->currentTestFile = $file; $this->currentTestFile = $file;
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
// 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') { if ($this->driver === 'pcov') {
\pcov\clear(); \pcov\clear();
\pcov\start(); \pcov\start();
@ -191,6 +225,79 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $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();
}
}
/**
* True when `$className` (or any of its ancestors) uses one of
* Laravel's database-resetting traits. Walking up `getTraits()` is
* necessary because Pest test classes are eval'd from the
* generated `*.php` test file and the trait usually lives on a
* shared `tests/TestCase.php` ancestor. Result is cached per class
* — class hierarchies don't change within a process.
*/
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
return $this->classUsesDatabaseCache[$className];
}
if (! class_exists($className, false)) {
return $this->classUsesDatabaseCache[$className] = false;
}
static $needles = [
'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
];
$reflection = new ReflectionClass($className);
do {
foreach (array_keys($reflection->getTraits()) as $traitName) {
if (isset($needles[$traitName])) {
return $this->classUsesDatabaseCache[$className] = true;
}
}
$reflection = $reflection->getParentClass();
} while ($reflection !== false && ! $reflection->isInternal());
return $this->classUsesDatabaseCache[$className] = false;
}
/** /**
* Records that the currently-running test queried `$table`. Called * Records that the currently-running test queried `$table`. Called
* by `TableTracker` for every DML statement Laravel's `DB::listen` * by `TableTracker` for every DML statement Laravel's `DB::listen`
@ -288,6 +395,14 @@ final class Recorder
return $out; return $out;
} }
/**
* @return array<string, true> absolute test file → true for tests using a Laravel DB-resetting trait.
*/
public function perTestUsesDatabase(): array
{
return $this->perTestUsesDatabase;
}
private function resolveTestFile(string $className, string $fallbackFile): ?string private function resolveTestFile(string $className, string $fallbackFile): ?string
{ {
if (array_key_exists($className, $this->classFileCache)) { if (array_key_exists($className, $this->classFileCache)) {
@ -355,7 +470,9 @@ final class Recorder
$this->perTestFiles = []; $this->perTestFiles = [];
$this->perTestTables = []; $this->perTestTables = [];
$this->perTestInertiaComponents = []; $this->perTestInertiaComponents = [];
$this->perTestUsesDatabase = [];
$this->classFileCache = []; $this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->active = false; $this->active = false;
} }
} }

View File

@ -105,30 +105,88 @@ final class TableExtractor
} }
/** /**
* @return list<string> Table names referenced by `Schema::` calls * @return list<string> Table names referenced by `Schema::` calls,
* in the given migration file contents. Empty * raw DDL, or DML inside the given migration
* when nothing matches — callers treat that * file contents. Empty when nothing matches —
* as "fall back to the broad watch pattern". * callers treat that as "fall back to the
* broad watch pattern".
*
* Three 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 …')`).
* 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
* `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
* Catches the seeded-lookup-table case where a migration
* populates rows that tests later read.
*
* False positives possible when the same syntax appears in a
* comment or unrelated string, but over-attribution is
* correctness-safe.
*/ */
public static function fromMigrationSource(string $php): array 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 = []; $tables = [];
foreach ($matches[1] as $i => $primary) { // Pass 1: Schema:: calls. `dropColumn` (singular) covers
// Group 1 always captures at least one char per the regex. // `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
$tables[strtolower($primary)] = true; // — 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 if (preg_match_all($schemaPattern, $php, $matches) !== false) {
// absent from non-rename matches. foreach ($matches[1] as $i => $primary) {
$secondary = $matches[2][$i] ?? ''; $tables[strtolower($primary)] = true;
if ($secondary !== '') {
$tables[strtolower($secondary)] = 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;
}
}
}
// Pass 3: DML inside migration bodies. Migrations that seed
// lookup tables via `DB::statement('INSERT INTO roles …')`,
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
// `DELETE FROM bar` are common in Laravel. Without picking
// these up, an edit to the seed payload would route through
// only the schema'd tables and silently skip every test that
// reads from the populated table. Fluent-builder calls
// (`DB::table('x')`) and raw SQL strings are both covered.
$dmlPatterns = [
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
];
foreach ($dmlPatterns as $pattern) {
if (preg_match_all($pattern, $php, $matches) === false) {
continue;
}
foreach ($matches[1] as $name) {
$lower = strtolower($name);
if (! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
} }
} }

View File

@ -43,12 +43,18 @@ final readonly class Inertia implements WatchDefault
'resources/js/Pages/**/*.tsx' => [$browserDir], 'resources/js/Pages/**/*.tsx' => [$browserDir],
'resources/js/Pages/**/*.jsx' => [$browserDir], 'resources/js/Pages/**/*.jsx' => [$browserDir],
'resources/js/Pages/**/*.svelte' => [$browserDir], 'resources/js/Pages/**/*.svelte' => [$browserDir],
'resources/js/Pages/**/*.ts' => [$browserDir],
'resources/js/Pages/**/*.js' => [$browserDir],
// Shared layouts / components consumed by pages. // Shared layouts / components consumed by pages.
'resources/js/Layouts/**/*.vue' => [$browserDir], 'resources/js/Layouts/**/*.vue' => [$browserDir],
'resources/js/Layouts/**/*.tsx' => [$browserDir], 'resources/js/Layouts/**/*.tsx' => [$browserDir],
'resources/js/Layouts/**/*.ts' => [$browserDir],
'resources/js/Layouts/**/*.js' => [$browserDir],
'resources/js/Components/**/*.vue' => [$browserDir], 'resources/js/Components/**/*.vue' => [$browserDir],
'resources/js/Components/**/*.tsx' => [$browserDir], 'resources/js/Components/**/*.tsx' => [$browserDir],
'resources/js/Components/**/*.ts' => [$browserDir],
'resources/js/Components/**/*.js' => [$browserDir],
// SSR entry point. // SSR entry point.
'resources/js/ssr.js' => [$browserDir], 'resources/js/ssr.js' => [$browserDir],

View File

@ -43,6 +43,21 @@ final readonly class Php implements WatchDefault
// tracked by the coverage driver. // tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath], '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],
// Pest dataset definitions are loaded once at boot, outside
// the per-test coverage window — no edge captures them. A
// change to a shared dataset can flip the result of any test
// that uses it, so broadcast every dataset edit to the full
// suite.
$testPath.'/Datasets/**/*.php' => [$testPath],
// Test fixtures — JSON, CSV, XML, TXT data files consumed by // Test fixtures — JSON, CSV, XML, TXT data files consumed by
// assertions. A fixture change can flip a test result. // assertions. A fixture change can flip a test result.
$testPath.'/Fixtures/**/*.json' => [$testPath], $testPath.'/Fixtures/**/*.json' => [$testPath],