Compare commits

...

11 Commits

Author SHA1 Message Date
be34eecb2f wip 2026-05-01 01:55:18 +01:00
5d9f95f8d4 qwdqwd 2026-05-01 01:44:08 +01:00
48b70a03d5 wip 2026-05-01 01:32:48 +01:00
4b8642b972 wip 2026-05-01 00:48:31 +01:00
8711d51eac fix 2026-05-01 00:19:44 +01:00
58dfb6da64 wip 2026-04-30 22:12:53 +01:00
d7735d1faa wip 2026-04-30 22:00:56 +01:00
6b59166f3c wip 2026-04-30 21:08:00 +01:00
3a26028d17 wip 2026-04-30 20:58:06 +01:00
3c91bf4ad2 wip 2026-04-30 20:51:57 +01:00
6a434be0f6 wip 2026-04-30 20:45:36 +01:00
18 changed files with 1361 additions and 1389 deletions

View File

@ -288,6 +288,7 @@ trait Testable
if ($cached !== null) {
if ($cached->isSuccess()) {
$this->__cachedPass = true;
$this->__ran = true;
return;
}
@ -299,6 +300,7 @@ trait Testable
// programmatic risky-marker API.
if ($cached->isRisky()) {
$this->__cachedPass = true;
$this->__ran = true;
return;
}
@ -313,6 +315,7 @@ trait Testable
if ($cached->isIncomplete()) {
$this->markTestIncomplete($cached->message());
$this->__ran = true;
}
throw new AssertionFailedError($cached->message() ?: 'Cached failure');

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
'',
]);
}
/**
* The exit code to be used.
*/
public function exitCode(): int
{
return 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,64 +11,39 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* Pulls a team-shared TIA baseline on the first `--tia` run so new
* contributors and fresh CI workspaces start in replay mode instead of
* paying the ~30s record cost.
* Downloads a team-shared TIA baseline from GitHub workflow artifacts so new contributors and
* fresh CI workspaces start in replay mode. Artifacts are used instead of releases because they
* produce no tag (no push cascade), support tunable retention, and can only be published by CI.
*
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
* suite under `--tia` and uploads the `.pest/tia/` directory as a named
* artifact (`pest-tia-baseline`) containing `graph.json` +
* `coverage.bin`. On dev
* machines, this class finds the latest successful run of that workflow
* and downloads the artifact via `gh`.
*
* Why artifacts, not releases:
* - No tag is created → no `push` event cascade into CI workflows.
* - No release event → no deploy workflows tied to `release:published`.
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
* a single floating tag.
* - Publishing is strictly CI-only: artifacts can't be produced from a
* developer's laptop. This enforces the "CI is the authoritative
* publisher" policy that local-publish paths would otherwise erode.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
* composer.lock, etc.) discards the pulled baseline and falls through to
* the regular record path.
* Fingerprint validation happens in `Tia::handleParent` after the blobs land; a mismatched
* environment falls through to the normal record path.
*
* @internal
*/
final readonly class BaselineSync
{
/**
* Conventional workflow filename teams publish from. Not configurable
* for MVP — teams that outgrow the default can set
* `PEST_TIA_BASELINE_WORKFLOW` later.
*/
private const string WORKFLOW_FILE = 'tia-baseline.yml';
/**
* Artifact name the workflow uploads under. The artifact is a zip
* containing `graph.json` (always) + `coverage.bin` (optional).
*/
private const string ARTIFACT_NAME = 'pest-tia-baseline';
/**
* Asset filenames inside the artifact — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
/**
* Cooldown (in seconds) applied after a failed baseline fetch.
* Rationale: when the remote workflow hasn't published yet, every
* `pest --tia` invocation would otherwise re-hit `gh run list` and
* re-print the publish instructions — noisy + slow. Back off for a
* day, let the user override with `--refetch`.
*/
// Project-local directory where artifacts from previous downloads are
// kept (one subfolder per workflow run id). Hitting the same run id on
// a later fetch skips the `gh run download` round trip entirely —
// artifacts are immutable per run id, so the cached bytes are exactly
// what gh would re-download.
private const string DOWNLOAD_CACHE_REL_DIR = '.pest/artifacts';
// Most recently downloaded artifacts to retain on disk. Branch
// switches and partial baseline rollouts hop across run ids — keeping
// the last few avoids re-downloading when the user toggles between
// them. Older entries get evicted on the next download.
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
// 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`.
private const int FETCH_COOLDOWN_SECONDS = 86400;
public function __construct(
@ -76,16 +51,6 @@ final readonly class BaselineSync
private OutputInterface $output,
) {}
/**
* Detects the repo, fetches the latest baseline artifact, writes its
* contents into the TIA state store. Returns true when the graph blob
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it.
*
* `$force = true` (driven by `--refetch`) ignores the post-failure
* cooldown so the user can retry on demand without waiting out the
* 24h window.
*/
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
@ -109,7 +74,7 @@ final readonly class BaselineSync
$repo,
));
$payload = $this->download($repo);
$payload = $this->download($repo, $projectRoot);
if ($payload === null) {
$this->startCooldown();
@ -126,9 +91,6 @@ final readonly class BaselineSync
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
// Successful fetch wipes any stale cooldown so the next failure
// (say, weeks later) starts a fresh 24h timer rather than inheriting
// one from the deep past.
$this->clearCooldown();
$this->output->writeln(sprintf(
@ -139,10 +101,6 @@ final readonly class BaselineSync
return true;
}
/**
* Seconds left on the cooldown, or `null` when the cooldown is cleared
* / expired / unreadable.
*/
private function cooldownRemaining(): ?int
{
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
@ -187,18 +145,6 @@ final readonly class BaselineSync
return $seconds.'s';
}
/**
* Prints actionable instructions for publishing a first baseline when
* the consumer-side fetch finds nothing.
*
* Behaviour splits on environment:
* - **CI:** a single line. The current run is almost certainly *the*
* publisher (it's what this workflow does by definition), so
* printing the whole recipe again is redundant and noisy.
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
* (`.env.example` copy + `artisan key:generate`) when the framework
* is present. Generic PHP projects get a slimmer skeleton.
*/
private function emitPublishInstructions(string $repo): void
{
if ($this->isCi()) {
@ -237,12 +183,7 @@ final readonly class BaselineSync
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
}
/**
* True when running inside a CI provider. Conservative list — only the
* three providers Pest formally supports / sees in the wild. `CI=true`
* alone is ambiguous (users set it locally too) so we require a
* provider-specific flag.
*/
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
private function isCi(): bool
{
return getenv('GITHUB_ACTIONS') === 'true'
@ -256,12 +197,6 @@ final readonly class BaselineSync
&& InstalledVersions::isInstalled('laravel/framework');
}
/**
* Laravel projects need a populated `.env` and a generated `APP_KEY`
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
* fires during `setUp`. Include the standard pre-test dance plus the
* extension set typical Laravel apps rely on.
*/
private function laravelWorkflowYaml(): string
{
return <<<'YAML'
@ -329,12 +264,6 @@ jobs:
YAML;
}
/**
* Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box.
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
* silently opts the repo out of auto-sync.
*/
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
@ -349,29 +278,20 @@ YAML;
return null;
}
// Find the `[remote "origin"]` section and the first `url` line
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
// SSH: git@github.com:org/repo(.git)
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $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];
}
@ -379,16 +299,8 @@ YAML;
return null;
}
/**
* Two-step fetch: find the latest successful run of the baseline
* workflow, then download the named artifact from it. Returns
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
* is missing, or any shell step fails.
*
* @return array{graph: string, coverage: ?string}|null
*/
private function download(string $repo): ?array
/** @return array{graph: string, coverage: ?string}|null */
private function download(string $repo, string $projectRoot): ?array
{
if (! $this->commandExists('gh')) {
return null;
@ -400,9 +312,20 @@ YAML;
return null;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
// Cache hit: a previous fetch already extracted this run id's
// artifact into the run-specific dir. Read the assets straight
// out of it and skip `gh run download` entirely.
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
// Bump the dir mtime so trimDownloadCache() treats this run
// id as recently used and doesn't evict it later.
@touch($runCacheDir);
return $this->readArtifact($runCacheDir);
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return null;
}
@ -410,43 +333,123 @@ YAML;
'gh', 'run', 'download', $runId,
'-R', $repo,
'-n', self::ARTIFACT_NAME,
'-D', $tmpDir,
'-D', $runCacheDir,
]);
$process->setTimeout(120.0);
$process->setTimeout(900.0);
$process->run();
if (! $process->isSuccessful()) {
$this->cleanup($tmpDir);
$this->cleanup($runCacheDir);
return null;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$payload = $this->readArtifact($runCacheDir);
if ($payload === null) {
$this->cleanup($runCacheDir);
return null;
}
$this->trimDownloadCache($projectRoot);
return $payload;
}
/**
* @return array{graph: string, coverage: ?string}|null
*/
private function readArtifact(string $dir): ?array
{
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
if ($graph === false) {
$this->cleanup($tmpDir);
return null;
}
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
$this->cleanup($tmpDir);
return [
'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage,
];
}
private function downloadCacheDir(string $projectRoot): string
{
return rtrim($projectRoot, '/\\')
.DIRECTORY_SEPARATOR
.str_replace('/', DIRECTORY_SEPARATOR, self::DOWNLOAD_CACHE_REL_DIR);
}
/**
* Queries GitHub for the most recent successful run of the baseline
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
* into an empty string, which we map to null.
* Run ids returned by `gh` are numeric strings, but defend against a
* surprising response by stripping anything non-alphanumeric — the
* value is used as a directory name.
*/
private function safeRunId(string $runId): string
{
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
return $sanitised === '' ? 'unknown' : $sanitised;
}
/**
* Keep the N most recently used cached artifacts and evict the rest.
* Recency is taken from the directory mtime — `mkdir`/`gh run download`
* stamps it on a fresh entry, and a cache hit `touch`es it back to
* the front of the line, so a frequently-reused run id won't be
* evicted just because newer ids have been seen between uses.
*/
private function trimDownloadCache(string $projectRoot): void
{
$root = $this->downloadCacheDir($projectRoot);
if (! is_dir($root)) {
return;
}
$entries = @scandir($root);
if ($entries === false) {
return;
}
$candidates = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) {
continue;
}
$mtime = @filemtime($path);
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
}
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
return;
}
usort(
$candidates,
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
);
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
$this->cleanup($stale['path']);
}
}
private function latestSuccessfulRunId(string $repo): ?string
{
$process = new Process([

View File

@ -27,17 +27,6 @@ final readonly class ChangedFiles
public function __construct(private string $projectRoot) {}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push) — in
* that case the graph should be rebuilt.
*/
/**
* Removes files whose current content hash matches the snapshot from the
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
* that git still reports as modified but whose content is bit-identical
* to the previous TIA invocation.
*
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
@ -48,12 +37,7 @@ final readonly class ChangedFiles
return $files;
}
// Union: `$files` (what git currently reports) + every path that was
// dirty last run. The second set matters for reverts — when a user
// undoes a local edit, the file matches HEAD again and git reports
// it clean, so it would never enter `$files`. But it has genuinely
// changed vs the snapshot we captured during the bad run, so it
// must be checked.
// Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
$candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) {
@ -68,28 +52,14 @@ final readonly class ChangedFiles
$exists = is_file($absolute);
if ($snapshot === null) {
// File wasn't in last-run tree at all — trust git's signal.
$remaining[] = $file;
continue;
}
if (! $exists) {
// Missing on disk. We always invalidate here, even when
// the snapshot also recorded "deleted" (sentinel '').
// The `snapshot=='' && !exists` shortcut would in
// principle say "no change since last run, cached
// result is still valid" — but it's only safe if the
// cached result was recorded *during* a run that saw
// the file as deleted. A previous run that captured
// the deletion in `lastRunTree` but failed to refresh
// the cached pass/fail (paratest worker race, an
// earlier plugin bug, etc.) would leave the cache
// stuck on a stale pass from before the deletion.
// Skipping invalidation in that state perpetuates the
// wrong result on every subsequent run. Treat any
// missing file as a change; cost is one re-run per
// `--tia` while the file stays deleted.
// Always invalidate deletions — a stale cached result from before the deletion
// would persist forever otherwise, even if the snapshot recorded the empty sentinel.
$remaining[] = $file;
continue;
@ -104,21 +74,9 @@ final readonly class ChangedFiles
}
if ($hash === $snapshot) {
// Same state as the last TIA invocation — cached
// result is still valid, no need to re-run.
continue;
}
// Differs from the snapshot. This includes the
// revert-back-to-baseline case (last run had a real edit
// and was cached against that edit; this run reverted).
// Even though the file now matches what's at the recorded
// SHA, the cached test result reflects the *modified*
// version, not the baseline version — so it's stale and
// the test must re-run to refresh the cache. An earlier
// version of this filter short-circuited on
// matches-baseline, which served the stale failure
// forever after the user reverted.
$remaining[] = $file;
}

View File

@ -24,6 +24,53 @@ use Pest\Support\Container;
*/
final class Configuration
{
/**
* Activates TIA for every run without requiring the `--tia` CLI flag.
*
* @return $this
*/
public function always(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
return $this;
}
/**
* Restricts the `always()` activation to local environments only.
* On CI (`--ci` flag or `CI` env var), TIA is skipped even if `always()` is set.
* Explicit `--tia` on the CLI always takes effect regardless.
*
* @return $this
*/
public function locally(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
return $this;
}
/**
* In replay mode, instead of short-circuiting cached results for unaffected
* tests, narrows PHPUnit to only the affected files — unaffected tests are
* never loaded. Can also be enabled with the `--filtered` CLI flag.
*
* @return $this
*/
public function filtered(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markFiltered();
return $this;
}
/**
* Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults.

View File

@ -5,30 +5,14 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Captures environmental inputs that, when changed, may make the TIA graph
* or its recorded results stale. The fingerprint is split into two buckets:
* Two-bucket fingerprint for TIA staleness detection.
*
* - **structural** — describes what the graph's *edges* were recorded
* 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). 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
* care of that automatically.
* - **structural**: inputs whose drift means graph *edges* may be wrong → full rebuild.
* `tests/TestCase.php` and `tests/Pest.php` are intentionally absent; they're covered by
* `Recorder::linkAncestorFiles` and the watch pattern, giving precise per-test invalidation.
* - **environmental**: runtime inputs (PHP version, extensions, env files) whose drift means
* edges are still valid but cached results may not reproduce → drop results and re-run.
* Pest's own version is absent; `composer.lock` moves whenever Pest is upgraded.
*
* @internal
*/
@ -83,7 +67,11 @@ final readonly class Fingerprint
// 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;
// v14: Node/Vite resolver inputs (`package*.json`, `tsconfig.*`,
// `jsconfig.*`) are included in the structural bucket. They can
// reshape the persisted JS module graph without touching
// `vite.config.*` itself.
private const int SCHEMA_VERSION = 14;
/**
* @return array{
@ -96,40 +84,19 @@ final readonly class Fingerprint
return [
'structural' => [
'schema' => self::SCHEMA_VERSION,
// `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_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
// Pest's generated classes bake the code-generation logic
// in — if TestCaseFactory changes (new attribute, different
// method signature, etc.) every previously-recorded edge is
// stale. Hashing via `ContentHash::of()` so cosmetic edits
// (comments, formatting) don't drift the fingerprint.
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::contentHashOrNull(__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.
'package_json' => self::packageJsonHash($projectRoot),
'package_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot),
'composer_json' => self::composerJsonHash($projectRoot),
],
'environmental' => [
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
// almost never matches a dev's Herd/Homebrew install, and
// the patch rarely changes anything test-visible.
// Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs.
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint($projectRoot),
'env_files' => self::envFilesHash($projectRoot),
@ -138,9 +105,6 @@ final readonly class Fingerprint
}
/**
* True when the structural buckets match. Drift here means the edges
* are potentially wrong; caller should discard the graph and rebuild.
*
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
@ -156,12 +120,6 @@ final readonly class Fingerprint
}
/**
* 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>
@ -195,11 +153,6 @@ final readonly class Fingerprint
}
/**
* Returns a list of field names that drifted between the stored and
* current environmental fingerprints. Empty list = no drift. Caller
* uses this to print a human-readable warning and to decide whether
* per-test results should be dropped (any drift → yes).
*
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
@ -244,12 +197,8 @@ final readonly class Fingerprint
return self::bucket($fingerprint, 'environmental');
}
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
/**
* Returns `$fingerprint[$key]` as an `array<string, mixed>` if it exists
* and is an array, otherwise empty. Legacy flat-shape fingerprints
* (schema ≤ 3) return empty here, which makes `structuralMatches` fail
* and the caller rebuild — the clean migration path.
*
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
@ -272,13 +221,6 @@ final readonly class Fingerprint
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 = [];
@ -294,12 +236,79 @@ 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 jsConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function packageJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/package.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;
}
$relevant = [
'type' => $data['type'] ?? null,
'packageManager' => $data['packageManager'] ?? null,
'dependencies' => $data['dependencies'] ?? null,
'devDependencies' => $data['devDependencies'] ?? null,
'optionalDependencies' => $data['optionalDependencies'] ?? null,
'peerDependencies' => $data['peerDependencies'] ?? null,
'overrides' => $data['overrides'] ?? null,
'resolutions' => $data['resolutions'] ?? null,
'imports' => $data['imports'] ?? null,
'exports' => $data['exports'] ?? null,
'browser' => $data['browser'] ?? null,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
private static function packageLockHash(string $projectRoot): ?string
{
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function envFilesHash(string $projectRoot): ?string
{
$paths = [
@ -348,14 +357,6 @@ final readonly class Fingerprint
return 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';
@ -403,15 +404,6 @@ final readonly class Fingerprint
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';
@ -492,12 +484,6 @@ final readonly class Fingerprint
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)) {
@ -537,16 +523,8 @@ final readonly class Fingerprint
return $hash === false ? null : $hash;
}
/**
* Deterministic hash of the extensions the project actually depends on —
* the `ext-*` entries in composer.json's `require` / `require-dev`. An
* incidental extension loaded on the developer's machine (or on CI) but
* not declared as a dependency can't affect correctness of the test
* suite, so we ignore it here to keep the drift signal quiet.
*
* Declared extensions that aren't currently loaded record as `missing`,
* which is itself a drift signal worth surfacing.
*/
// Only hashes `ext-*` entries declared in composer.json — incidental extensions loaded on the
// machine but not declared can't affect suite correctness, so they're excluded to reduce noise.
private static function extensionsFingerprint(string $projectRoot): string
{
$extensions = self::declaredExtensions($projectRoot);
@ -567,15 +545,7 @@ final readonly class Fingerprint
return hash('xxh128', implode("\n", $parts));
}
/**
* Extension names (without the `ext-` prefix) that appear as keys under
* `require` or `require-dev` in the project's composer.json. Returns
* an empty list when composer.json is missing / unreadable / malformed,
* so the environmental fingerprint stays stable in those cases rather
* than flapping.
*
* @return list<string>
*/
/** @return list<string> */
private static function declaredExtensions(string $projectRoot): array
{
$path = $projectRoot.'/composer.json';

View File

@ -11,122 +11,47 @@ use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
/**
* File-level Test Impact Analysis graph.
*
* Persists the mapping `test_file → set<source_file>` so that subsequent runs
* can skip tests whose dependencies have not changed. Paths are stored relative
* to the project root and source files are deduplicated via an index so that
* the on-disk JSON stays compact for large suites.
* Dependency graph: test file → set<source file>. Skips unchanged tests on replay.
* Source files are indexed by numeric id to keep the on-disk JSON compact.
*
* @internal
*/
final class Graph
{
/**
* Relative path of each known source file, indexed by numeric id.
*
* @var array<int, string>
*/
/** @var array<int, string> */
private array $files = [];
/**
* Reverse lookup: source file → numeric id.
*
* @var array<string, int>
*/
/** @var array<string, int> */
private array $fileIds = [];
/**
* Edges: test file (relative) → list of source file ids.
*
* @var array<string, array<int, int>>
*/
/** @var array<string, array<int, int>> */
private array $edges = [];
/**
* Table edges: test file (relative) → list of lowercase SQL table
* names the test queried during record. Populated from the
* Recorder's `perTestTables()` snapshot; consumed at replay time
* to do surgical invalidation when a migration changes — the
* test only re-runs if its set intersects the tables the changed
* migration touches. Empty for tests that never hit the DB, which
* is exactly why those tests stay unaffected by migration edits.
*
* Unlike `$edges`, we store names rather than ids: the table
* universe is small (hundreds at most on a giant app), storing
* strings keeps the on-disk graph diff-readable, and the lookup
* cost is negligible compared to the per-file ids used above.
*
* @var array<string, array<int, string>>
*/
/** @var array<string, array<int, string>> */
private array $testTables = [];
/**
* Inertia page component edges: test file (relative) → list of
* component names the test server-side rendered (whatever was
* passed to `Inertia::render($component, …)`). Populated from
* `Recorder::perTestInertiaComponents()`; consumed at replay time
* so an edit to `resources/js/Pages/Users/Show.vue` only invalidates
* tests that rendered `Users/Show`. Same string-keyed shape as
* `$testTables` for the same diff-readable reasons.
*
* @var array<string, array<int, string>>
*/
/** @var array<string, array<int, string>> */
private array $testInertiaComponents = [];
/**
* Inverted JS dependency map: project-relative source path under
* `resources/js/**` → list of Inertia page components that
* transitively import it. Populated at record time by
* `JsModuleGraph::build()` (Vite module graph via Node helper,
* with a PHP fallback). Replay uses this to route a
* `Components/Button.vue` edit directly to the pages that depend
* on it, intersecting against `$testInertiaComponents` for
* surgical invalidation.
*
* @var array<string, array<int, string>>
*/
/** @var array<string, array<int, string>> */
private array $jsFileToComponents = [];
/**
* Environment fingerprint captured at record time.
*
* @var array<string, mixed>
*/
/** @var array<string, mixed> */
private array $fingerprint = [];
/**
* Per-branch baselines. Each branch independently tracks:
* - `sha` — last HEAD at which `--tia` ran on this branch
* - `tree` — content hashes of modified files at that point
* - `results` — per-test status + message + time
*
* Graph edges (test → source) stay shared across branches because
* structure doesn't change per branch. Only run-state is per-branch so
* a failing test on one branch doesn't poison another branch's replay.
*
* @var array<string, array{
* sha: ?string,
* tree: array<string, string>,
* results: array<string, array{status: int, message: string, time: float, assertions?: int}>
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
* }>
*/
private array $baselines = [];
/**
* Canonicalised project root. Resolved through `realpath()` so paths
* captured by coverage drivers (always real filesystem targets) match
* regardless of whether the user's CWD is a symlink or has trailing
* separators.
*/
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
private readonly string $projectRoot;
/**
* Cached project-relative test files that contain at least one test in the
* `arch` group.
*
* @var array<string, true>|null
*/
/** @var array<string, true>|null */
private ?array $archTestFiles = null;
public function __construct(string $projectRoot)
@ -136,9 +61,6 @@ final class Graph
$this->projectRoot = $real !== false ? $real : $projectRoot;
}
/**
* Records that a test file depends on the given source file.
*/
public function link(string $testFile, string $sourceFile): void
{
$testRel = $this->relative($testFile);
@ -158,20 +80,11 @@ final class Graph
}
/**
* Returns the set of test files whose dependencies intersect $changedFiles.
*
* Two resolution paths:
* 1. **Coverage edges** — test depends on a PHP source file that changed.
* 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a
* glob that maps to a test directory; every test under that directory
* is affected.
*
* @param array<int, string> $changedFiles Absolute or relative paths.
* @return array<int, string> Relative test file paths.
* @return array<int, string>
*/
public function affected(array $changedFiles): array
{
// Normalise all changed paths once.
$normalised = [];
foreach ($changedFiles as $file) {
@ -184,15 +97,9 @@ final class Graph
$affectedSet = [];
// Migration changes don't flow through the coverage-edge path —
// `RefreshDatabase` in every test's `setUp()` means every test
// has an edge to every migration, so step 1 would re-run the
// whole DB-touching suite on any migration edit. Route them
// separately: static-parse the migration source, union the
// referenced tables, and match tests whose recorded query
// footprint intersects that set. Missed files (rare: migrations
// with pure raw SQL or dynamic names) fall back to the watch
// pattern below.
// Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
// every migration, so any migration change would re-run the whole DB suite. Route them via
// table-intersection instead; unparseable migrations fall through to the watch pattern.
$migrationPaths = [];
$nonMigrationPaths = [];
@ -237,16 +144,22 @@ final class Graph
}
}
// Inertia page-component routing. When a page under
// `resources/js/Pages/` changes, map it to the component name
// Inertia would use (the path relative to `Pages/`, extension
// stripped) and intersect with the captured component edges.
// Only invalidates tests that actually rendered the page.
// Pages with no captured edges (never rendered during record,
// brand-new on this branch) fall through to the watch-pattern
// fallback — safe over-run. Pages handled here are tracked in
// `$preciselyHandledPages` so the watch broadcast and JS-dep
// lookup don't re-route them.
// Inertia page routing: map changed page files to component names and intersect with recorded
// component edges. Pages with no captured edges fall through to the watch pattern.
$globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) {
if (! $this->isGlobalFrontendRuntimePath($rel)) {
continue;
}
foreach (array_keys($this->testInertiaComponents) as $testFile) {
$affectedSet[$testFile] = true;
}
$globalFrontendRuntimeFiles[$rel] = true;
}
$changedComponents = [];
$preciselyHandledPages = [];
@ -263,17 +176,13 @@ final class Graph
}
}
// Shared JS files (Components, Layouts, composables, etc.)
// aren't Inertia pages but pages depend on them transitively.
// `$jsFileToComponents` was computed at record time by walking
// Vite's module graph, so a change to
// `resources/js/Components/Button.vue` resolves directly to
// the set of page components that import it. Union those into
// `$changedComponents`. Files that aren't in the JS dep map
// fall through to the watch pattern below — same safety-net
// path the Inertia block above uses for unresolved pages.
// Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
// Files absent from the map fall through to the watch pattern.
$sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
}
if (isset($preciselyHandledPages[$rel])) {
continue;
}
@ -295,23 +204,14 @@ 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.
// New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
// import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
// trustworthy enough to honour a negative (PHP parser can miss custom aliases).
$newJsFiles = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
}
if (isset($preciselyHandledPages[$rel])) {
continue;
}
@ -331,12 +231,8 @@ final class Graph
$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.
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
// knows precision was downgraded rather than leaving the slower replay unexplained.
$output = Container::getInstance()->get(OutputInterface::class);
if ($output instanceof OutputInterface) {
$output->writeln(sprintf(
@ -349,9 +245,7 @@ final class Graph
$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.
// Vite confirms no page imports this file — suppress the watch broadcast.
$sharedFilesResolved[$rel] = true;
continue;
@ -388,9 +282,8 @@ final class Graph
}
}
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already
// handled above; skipping them here prevents their always-on
// coverage edges from invalidating the whole DB suite.
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
// their always-on edges from re-running the whole DB suite.
$changedIds = [];
$unknownSourceDirs = [];
$sourcePhpChanged = false;
@ -410,28 +303,18 @@ final class Graph
$absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) {
// Deleted source file unknown to the graph — can't affect
// any test because no edge ever pointed to it.
// Deleted source file unknown to the graph — no edge ever pointed to it.
continue;
}
// Source PHP file unknown to the graph — might be a new file
// that only exists on this branch (graph inherited from main).
// Only use the sibling heuristic for files that commonly
// participate in framework discovery / bootstrap. Ordinary new
// classes, enums, DTOs, services, etc. should not re-run sibling
// tests just because they live in the same directory.
if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
$unknownSourceDirs[dirname($rel)] = true;
}
}
}
// 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 tests in Pest's `arch` group run, not the
// suite.
// Arch tests inspect structure by namespace/path, never producing coverage edges for the files
// they examine — so a new class can fail an arch expectation without any edge to it.
if ($sourcePhpChanged) {
foreach (array_keys($this->edges) as $testFile) {
if ($this->isArchTestFile($testFile)) {
@ -454,11 +337,8 @@ final class Graph
}
}
// Unknown Blade files can still be routed precisely when another
// recorded Blade view statically references them (`@include`,
// `@extends`, `<x-alert />`, etc.). Walk the source-level Blade graph
// upward to rendered ancestors and invalidate tests that rendered those
// ancestors instead of broadcasting every Blade edit to the whole suite.
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
// ancestors and invalidate only tests that covered them.
$staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) {
@ -480,29 +360,13 @@ final class Graph
$staticallyHandledBlade[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) {
// Anonymous Blade components are leaf templates. If nothing in
// the project statically renders the component, treat it like an
// orphan rather than running the full suite.
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
$staticallyHandledBlade[$rel] = true;
}
}
// 2. Watch-pattern lookup — fallback for files we don't have
// precise edges for. When a file is already in `$fileIds` step
// 1 resolved it surgically; broadcasting it again through the
// watch pattern would re-add every test the pattern maps to,
// defeating the point of recording the edge in the first place.
// Blade templates captured via Laravel's view composer are the
// motivating case — we want their specific tests, not every
// feature test. Migrations whose static parse yielded nothing
// (exotic syntax, raw SQL) are funneled back in here too so
// broad invalidation still kicks in for edge cases we can't
// parse.
// Exclude paths that were already routed precisely through
// either the Inertia page-component path or the shared-JS
// dependency path. Broadcasting them again via the watch
// pattern would re-add every test the pattern maps to,
// defeating the surgical match.
// Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded
// to avoid re-broadcasting via the watch pattern and defeating the surgical match.
$unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) {
@ -516,8 +380,7 @@ final class Graph
}
if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever
// pointed to it, so it can't affect any test.
// Deleted file unknown to the graph — no edge ever pointed to it.
continue;
}
@ -535,22 +398,9 @@ final class Graph
$affectedSet[$testFile] = true;
}
// 3. Sibling heuristic for unknown source files.
//
// When a PHP source file is unknown to the graph (no test depends on
// it), it is either genuinely untested OR it was added on a branch
// whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file.
//
// To avoid silent misses for framework-discovered files: find tests
// that already cover ANY file in the same directory. If
// `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
// listeners are covered by a mail-flow test, run that test — it likely
// exercises the same discovery surface.
//
// This over-runs slightly (sibling may be unrelated) but never
// under-runs. And once the test executes, its coverage captures the
// new file → graph self-heals for next run.
// Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from
// another branch. Run tests that cover neighbouring files in the same directory so framework-
// discovered files (Listeners, Events, Policies, etc.) aren't silently missed.
if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) {
@ -576,9 +426,6 @@ final class Graph
return array_keys($affectedSet);
}
/**
* Returns `true` if the given test file has any recorded dependencies.
*/
public function knowsTest(string $testFile): bool
{
$rel = $this->relative($testFile);
@ -586,9 +433,7 @@ final class Graph
return $rel !== null && isset($this->edges[$rel]);
}
/**
* @return array<int, string> All project-relative test files the graph knows.
*/
/** @return array<int, string> */
public function allTestFiles(): array
{
return array_keys($this->edges);
@ -610,12 +455,6 @@ final class Graph
return $this->fingerprint;
}
/**
* Returns the SHA the given branch last ran against, or falls back to
* `$fallbackBranch` (typically `main`) when this branch has no baseline
* yet. That way a freshly-created feature branch inherits main's
* baseline on its first run.
*/
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@ -629,7 +468,7 @@ final class Graph
$this->baselines[$branch]['sha'] = $sha;
}
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0): void
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [
@ -638,14 +477,16 @@ final class Graph
'time' => $time,
'assertions' => $assertions,
];
if ($file !== null) {
$rel = $this->relative($file);
if ($rel !== null) {
$this->baselines[$branch]['results'][$testId]['file'] = $rel;
}
}
}
/**
* Returns the cached assertion count for a test, or `null` if unknown.
* Callers use this to feed `addToAssertionCount()` at replay time so
* the "Tests: N passed (M assertions)" banner matches the recorded run
* instead of defaulting to 1 assertion per test.
*/
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@ -684,6 +525,58 @@ final class Graph
};
}
/**
* @return array<int, string>
*/
public function failedOrErroredTestFiles(string $branch, string $fallbackBranch = 'main'): array
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
$files = [];
foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null;
if ($status !== 7 && $status !== 8) {
continue;
}
$file = $result['file'] ?? null;
if (! is_string($file) || $file === '') {
continue;
}
$rel = $this->relative($file);
if ($rel !== null) {
$files[$rel] = true;
}
}
return array_keys($files);
}
public function hasUnlocatedFailuresOrErrors(string $branch, string $fallbackBranch = 'main'): bool
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null;
if ($status !== 7 && $status !== 8) {
continue;
}
$file = $result['file'] ?? null;
if (! is_string($file) || $file === '' || $this->relative($file) === null) {
return true;
}
}
return false;
}
/**
* @param array<string, string> $tree project-relative path → content hash
*/
@ -693,14 +586,7 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree;
}
/**
* Wipes cached per-test results for the given branch. Edges and tree
* snapshot stay intact — the graph still describes the code correctly,
* only the "what happened last time" data is reset. Used on
* environmental fingerprint drift: the edges were recorded elsewhere
* (e.g. CI) so they're still valid, but the results aren't trustworthy
* on this machine until the tests re-run here.
*/
// Edges and tree snapshot stay intact; only the run-state is reset.
public function clearResults(string $branch): void
{
$this->ensureBaseline($branch);
@ -716,7 +602,7 @@ final class Graph
}
/**
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int}>}
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
*/
private function baselineFor(string $branch, string $fallbackBranch): array
{
@ -739,9 +625,6 @@ final class Graph
}
/**
* Replaces edges for the given test files. Used during a partial record
* run so that existing edges for other tests are preserved.
*
* @param array<string, array<int, string>> $testToFiles
*/
public function replaceEdges(array $testToFiles): void
@ -765,12 +648,6 @@ final class Graph
}
/**
* Replaces table edges for the given test files. Table names are
* lowercased + deduplicated; the input comes straight from the
* Recorder's `perTestTables()` snapshot. Tests absent from the
* input keep their existing table set (same partial-update policy
* as `replaceEdges`).
*
* @param array<string, array<int, string>> $testToTables
*/
public function replaceTestTables(array $testToTables): void
@ -800,11 +677,6 @@ final class Graph
}
/**
* Replaces Inertia component edges for the given test files. Names
* preserve case (they're identifiers like `Users/Show`, not
* user-supplied strings) but duplicates are collapsed. Same
* partial-update policy as `replaceTestTables`.
*
* @param array<string, array<int, string>> $testToComponents
*/
public function replaceTestInertiaComponents(array $testToComponents): void
@ -831,16 +703,8 @@ final class Graph
}
}
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
/**
* Replaces the whole JS dep map. Called at record time with the
* output of `JsModuleGraph::build()`. Empty input is treated as a
* resolver failure (Node missing, Vite refused to load, transient
* `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
*/
public function replaceJsFileToComponents(array $fileToComponents): void
@ -877,23 +741,11 @@ final class Graph
$this->jsFileToComponents = $out;
}
/**
* Projects under Laravel conventionally keep migrations at
* `database/migrations/`. We recognise the directory as a prefix
* so nested subdirectories (a pattern some teams use for grouping
* — `database/migrations/tenant/`, `database/migrations/archived/`)
* are still routed through the table-intersection path.
*/
private function isMigrationPath(string $rel): bool
{
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
}
/**
* Unknown PHP files have no historical edge yet. Keep sibling fan-out only
* for framework-discovered / boot-loaded conventions where adding a file can
* change behaviour without another source file changing too.
*/
private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
{
static $prefixes = [
@ -905,6 +757,14 @@ final class Graph
'app/Console/Commands/',
'app/Mail/',
'app/Notifications/',
'app/Nova/Actions/',
'app/Nova/Dashboards/',
'app/Nova/Lenses/',
'app/Nova/Metrics/',
'app/Nova/Policies/',
'app/Nova/Resources/',
'app/Projectors/',
'app/Reactors/',
'database/factories/',
'database/seeders/',
];
@ -1204,15 +1064,7 @@ final class Graph
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
}
/**
* Reads `$rel` relative to the project root and extracts the
* tables it declares via `Schema::create/table/drop/rename`.
* Empty on missing/unreadable files or when the parser finds
* nothing — the caller escalates those cases to the watch
* pattern safety net.
*
* @return list<string>
*/
/** @return list<string> */
private function tablesForMigration(string $rel): array
{
$absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel;
@ -1230,21 +1082,7 @@ final class Graph
return TableExtractor::fromMigrationSource($content);
}
/**
* Maps a project-relative path to its Inertia component name if it
* lives under the project's pages directory with a recognised
* framework extension. Returns null otherwise so callers can
* cheaply ignore non-page files. Matches Inertia's resolver
* convention: strip the pages prefix, strip the extension, preserve
* the remaining slashes (`Users/Show.vue` → `Users/Show`).
*
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
* and `resources/js/pages/` (the Laravel React starter kit, and
* other lowercase-by-default setups) are accepted — paths from
* git are case-sensitive on Linux, so we must match the exact
* casing used by the project rather than picking one and forcing
* the other to fall through to the broad watch pattern.
*/
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
private function componentForInertiaPage(string $rel): ?string
{
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
@ -1273,13 +1111,27 @@ final class Graph
return null;
}
/**
* Whether any test's component set contains `$component`. Used to
* decide between precise edge matching and watch-pattern fallback
* for a changed Inertia page file.
*
* @param array<string, array<int, string>> $edges
*/
private function isGlobalFrontendRuntimePath(string $rel): bool
{
if (! str_starts_with($rel, 'resources/js/')) {
return false;
}
$tail = substr($rel, strlen('resources/js/'));
$dot = strrpos($tail, '.');
if ($dot === false) {
return false;
}
$name = substr($tail, 0, $dot);
$extension = substr($tail, $dot + 1);
return in_array($extension, ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte'], true)
&& in_array($name, ['App', 'app', 'bootstrap', 'echo', 'favicon'], true);
}
/** @param array<string, array<int, string>> $edges */
private function anyTestUses(array $edges, string $component): bool
{
foreach ($edges as $components) {
@ -1291,11 +1143,6 @@ final class Graph
return false;
}
/**
* Drops edges whose test file no longer exists on disk. Prevents the graph
* from keeping stale entries for deleted / renamed tests that would later
* be flagged as affected and confuse PHPUnit's discovery.
*/
public function pruneMissingTests(): void
{
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
@ -1319,12 +1166,6 @@ final class Graph
}
}
/**
* Rebuilds a graph from its JSON representation. Returns `null` when
* the payload is missing, unreadable, or schema-incompatible. Separated
* from transport (state backend, file, etc.) so tests can feed bytes
* directly without touching disk.
*/
public static function decode(string $json, string $projectRoot): ?self
{
$data = json_decode($json, true);
@ -1412,12 +1253,6 @@ final class Graph
return $graph;
}
/**
* Serialises the graph to its JSON on-disk form. Returns `null` if the
* payload can't be encoded (extremely rare — pathological UTF-8 only).
* Persistence is the caller's responsibility: write the returned bytes
* through whatever `State` implementation is in play.
*/
public function encode(): ?string
{
$payload = [
@ -1436,15 +1271,8 @@ final class Graph
return $json === false ? null : $json;
}
/**
* Normalises a path to be relative to the project root; returns `null` for
* paths we should ignore (outside the project, unknown, virtual, vendor).
*
* Accepts both absolute paths (from Xdebug/PCOV coverage) and
* project-relative paths (from `git diff`) — we normalise without relying
* on `realpath()` of relative paths because the current working directory
* is not guaranteed to be the project root.
*/
// Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
// Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
private function relative(string $path): ?string
{
if ($path === '' || $path === 'unknown') {
@ -1471,12 +1299,9 @@ final class Graph
return null;
}
// Always normalise to forward slashes. Windows' native separator
// would otherwise produce keys that never match paths reported
// by `git` (which always uses forward slashes).
// Always forward slashes — git always uses them; Windows backslashes would never match.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else {
// Normalise directory separators and strip any "./" prefix.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
while (str_starts_with($relative, './')) {
@ -1484,10 +1309,6 @@ final class Graph
}
}
// Vendor packages are pinned by composer.lock. Any upgrade bumps the
// fingerprint and invalidates the graph wholesale, so there is no
// reason to track individual vendor files — doing so inflates the
// graph by orders of magnitude on Laravel-style projects.
if (str_starts_with($relative, 'vendor/')) {
return null;
}

View File

@ -12,23 +12,29 @@ use Symfony\Component\Process\Process;
* `resources/js/**` — for every source file, the list of Inertia page
* components that transitively import it.
*
* Tries two resolvers in order:
* Backed by a Node helper (`bin/pest-tia-vite-deps.mjs`) that boots a
* headless Vite server in middleware mode, walks Vite's own module
* graph for each page entry, and outputs JSON. Uses the project's real
* `vite.config.*`, so aliases, plugins, and SFC transformers produce
* the exact graph Vite itself would use.
*
* 1. **Node helper** (`bin/pest-tia-vite-deps.mjs`). Spins up a
* headless Vite server in middleware mode, walks Vite's own
* module graph for each page entry, and outputs JSON. Uses the
* project's real `vite.config.*`, so aliases, plugins, and SFC
* transformers produce the same graph Vite itself would use.
* Two latency mitigations:
*
* 2. **PHP fallback** (`JsImportParser`). Regex-scans ES imports
* and resolves `@/` / `~/` aliases manually. Strictly less
* precise — anything it can't resolve is skipped, leaving the
* caller to fall back to the broad watch pattern. Only kicks in
* when the Node helper is unusable (no Node on PATH, no Vite
* installed, vite.config fails to load).
* 1. **Content-hash cache** keyed by every file under `resources/js/`
* (path + size + mtime) plus the bytes of `vite.config.*` and the
* pages-directory casing. When inputs are unchanged, the 13s+ Node
* bootstrap is skipped entirely and the previous result is reused.
*
* Callers invoke this at record time; results are persisted into the
* graph so replay never re-runs the resolver. On stale-map detection
* 2. **Background warmer** — `warmInBackground()` is called at suite
* start. It computes the fingerprint, checks the cache, and only
* spawns Node if a refresh is needed. The subprocess runs in
* parallel with the test suite. By the time `build()` is called at
* flush time, the result is usually already on stdout — `wait()`
* returns instantly. If tests finish faster than Vite boots,
* `build()` simply pays the remainder, never the full bootstrap.
*
* Callers invoke `build()` at record time; results are persisted into
* the graph so replay never re-runs the resolver. On stale-map detection
* the callers decide whether to rebuild.
*
* @internal
@ -37,36 +43,94 @@ final class JsModuleGraph
{
private const int NODE_TIMEOUT_SECONDS = 25;
private const string CACHE_FILE = 'js-module-graph.cache.json';
/** Active warmer subprocess, or null when none is in flight. */
private static ?Process $warmer = null;
/** Fingerprint the warmer was started against — used to detect drift between warm and build. */
private static ?string $warmerFingerprint = null;
/** True when the warmer found a fresh cache and skipped spawning Node. */
private static bool $warmerCacheHit = false;
/** Project root the warmer was launched for. */
private static ?string $warmerProjectRoot = null;
/**
* Kicks off the Node helper in the background, so by the time
* `build()` is called at flush time the result is (usually) already
* sitting on stdout. Idempotent — a second call while a warmer is
* already in flight is a no-op. Cheap when the cache is fresh: it
* checks the fingerprint first and skips the subprocess.
*
* Safe to call from any TIA entry point that will eventually write
* the graph from the main process. Workers must NOT call this — they
* don't flush the graph and would duplicate the Node bootstrap on
* every worker.
*/
public static function warmInBackground(string $projectRoot): void
{
if (self::$warmer !== null || self::$warmerCacheHit) {
return;
}
if (! self::isApplicable($projectRoot)) {
return;
}
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null && self::readCache($projectRoot, $fingerprint) !== null) {
self::$warmerCacheHit = true;
self::$warmerFingerprint = $fingerprint;
self::$warmerProjectRoot = $projectRoot;
return;
}
$process = self::buildNodeProcess($projectRoot);
if ($process === null) {
return;
}
try {
$process->start();
} catch (\Throwable) {
return;
}
self::$warmer = $process;
self::$warmerFingerprint = $fingerprint;
self::$warmerProjectRoot = $projectRoot;
register_shutdown_function(self::reapWarmer(...));
}
/**
* @return array<string, list<string>> project-relative source path → sorted list of page component names
*/
public static function build(string $projectRoot): array
{
$viaNode = self::tryNodeHelper($projectRoot);
$result = self::resolve($projectRoot);
if ($viaNode !== null) {
return $viaNode;
}
return JsImportParser::parse($projectRoot);
return $result ?? [];
}
/**
* 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.
* Strict variant — returns null when the Node resolver isn't
* available, so callers can distinguish "Vite says nothing imports
* this file" (empty list) from "we couldn't ask Vite" (null).
*
* 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.
* (i.e., "no page imports this file, so it's orphan, safe to skip").
*
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
{
return self::tryNodeHelper($projectRoot);
return self::resolve($projectRoot);
}
/**
@ -97,7 +161,100 @@ final class JsModuleGraph
/**
* @return array<string, list<string>>|null
*/
private static function tryNodeHelper(string $projectRoot): ?array
private static function resolve(string $projectRoot): ?array
{
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) {
self::reapWarmer();
return $cached;
}
}
// Pick up the warmer when it was launched against the same
// fingerprint and project root. Drift between warm and build
// (rare — would require a JS file to change mid-test-run)
// discards the warmer and re-runs synchronously.
if (self::$warmerCacheHit
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot
&& $fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
self::$warmerCacheHit = false;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
if ($cached !== null) {
return $cached;
}
}
if (self::$warmer !== null
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot) {
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
try {
$process->wait();
} catch (\Throwable) {
// fall through to synchronous run
$process = null;
}
if ($process !== null && $process->isSuccessful()) {
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null) {
if ($fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
}
} else {
// Different fingerprint or different project root: discard
// any stale warmer before we start a fresh run.
self::reapWarmer();
}
$viaNode = self::runNodeSync($projectRoot);
if ($viaNode !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $viaNode);
}
return $viaNode;
}
/**
* @return array<string, list<string>>|null
*/
private static function runNodeSync(string $projectRoot): ?array
{
$process = self::buildNodeProcess($projectRoot);
if ($process === null) {
return null;
}
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return self::parseNodeOutput($process->getOutput());
}
private static function buildNodeProcess(string $projectRoot): ?Process
{
if (! self::hasViteConfig($projectRoot)) {
return null;
@ -135,14 +292,17 @@ final class JsModuleGraph
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
$process->run();
if (! $process->isSuccessful()) {
return null;
return $process;
}
/**
* @return array<string, list<string>>|null
*/
private static function parseNodeOutput(string $output): ?array
{
/** @var mixed $decoded */
$decoded = json_decode($process->getOutput(), true);
$decoded = json_decode($output, true);
if (! is_array($decoded)) {
return null;
@ -176,6 +336,200 @@ final class JsModuleGraph
return $out;
}
/**
* Stop and discard a leftover warmer subprocess (e.g. on shutdown,
* or when `build()` resolved from cache without needing the warmer).
*/
private static function reapWarmer(): void
{
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
self::$warmerCacheHit = false;
if ($process === null) {
return;
}
try {
if ($process->isRunning()) {
$process->stop(0.5);
}
} catch (\Throwable) {
// best-effort cleanup
}
}
/**
* Content fingerprint of every input that can change the Node/Vite
* module graph: each `resources/js/**` source (path + size + mtime),
* each `vite.config.*` (path + size + mtime + sha-of-bytes), and
* the chosen pages-directory casing. Returns null only when no
* `vite.config.*` exists — i.e. the resolver itself wouldn't run.
*
* File inputs use `mtime+size` rather than full content hashes —
* walking thousands of SFCs and re-hashing them on every flush
* would defeat the point of the cache. mtime/size collisions on
* an edited file are theoretically possible but vanishingly rare,
* and the cost of a rare miss (one extra Node run) is exactly what
* the cache costs anyway. The vite config itself is small and
* load-bearing for plugin/alias behaviour, so we hash its bytes
* outright.
*/
private static function fingerprint(string $projectRoot): ?string
{
if (! self::hasViteConfig($projectRoot)) {
return null;
}
$parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) {
continue;
}
$stat = @stat($path);
$bytes = @file_get_contents($path);
$parts[] = 'config:'.$name
.':'.($stat === false ? '0' : (string) $stat['mtime'])
.':'.($stat === false ? '0' : (string) $stat['size'])
.':'.($bytes === false ? '' : hash('sha256', $bytes));
}
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
$parts[] = 'pagesDir:'.$dir;
break;
}
}
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
if (is_dir($jsRoot)) {
$entries = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
}
sort($entries);
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
}
return hash('sha256', implode('|', $parts));
}
/**
* @return array<string, list<string>>|null
*/
private static function readCache(string $projectRoot, string $fingerprint): ?array
{
$path = self::cachePath($projectRoot);
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
/** @var mixed $decoded */
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
return null;
}
$graph = $decoded['graph'] ?? null;
if (! is_array($graph)) {
return null;
}
$out = [];
foreach ($graph as $key => $value) {
if (! is_string($key) || ! is_array($value)) {
continue;
}
$names = [];
foreach ($value as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}
$out[$key] = $names;
}
return $out;
}
/**
* @param array<string, list<string>> $graph
*/
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{
$path = self::cachePath($projectRoot);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$payload = json_encode([
'fingerprint' => $fingerprint,
'graph' => $graph,
]);
if ($payload === false) {
return;
}
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $payload) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
}
private static function cachePath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function hasViteConfig(string $projectRoot): bool
{
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {

View File

@ -8,119 +8,49 @@ use Pest\TestSuite;
use ReflectionClass;
/**
* Captures per-test file coverage using the PCOV driver.
*
* Acts as a singleton because PCOV has a single global collection state and
* the recorder is wired into PHPUnit through two distinct subscribers
* (`Prepared` / `Finished`) that must share context.
* Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state
* shared across the `Prepared` and `Finished` subscribers.
*
* @internal
*/
final class Recorder
{
/**
* Test file currently being recorded, or `null` when idle.
*/
private ?string $currentTestFile = null;
/**
* Aggregated map: absolute test file → set<absolute source file>.
*
* @var array<string, array<string, true>>
*/
/** @var array<string, array<string, true>> */
private array $perTestFiles = [];
/**
* Aggregated map: absolute test file → set<lowercase table name>.
* Populated by `TableTracker` from `DB::listen` callbacks; consumed
* at record finalize to populate the graph's `$testTables` edges
* that drive migration-change impact analysis.
*
* @var array<string, array<string, true>>
*/
/** @var array<string, array<string, true>> */
private array $perTestTables = [];
/**
* Aggregated map: absolute test file → set<Inertia component name>.
* Populated by `InertiaEdges` from Inertia responses observed at
* request-handled time; consumed at record finalize to populate
* the graph's per-test component edges that drive Vue / React
* page-file impact analysis.
*
* @var array<string, array<string, true>>
*/
/** @var array<string, array<string, true>> */
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>
*/
/** @var array<string, true> */
private array $perTestUsesDatabase = [];
/**
* Cached class → test file resolution.
*
* @var array<string, string|null>
*/
/** @var array<string, string|null> */
private array $classFileCache = [];
/**
* Cached class → "uses Laravel DB trait" introspection result.
*
* @var array<string, bool>
*/
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
/**
* Reverse map of project-local source file → list of class /
* interface / trait names declared in it. Built incrementally as
* tests run and new classes get autoloaded; consumed by
* `linkSourceDependencies()` so a test's covered file's
* declared classes can be walked for their interfaces, traits,
* and parents (which the coverage driver doesn't capture
* because interface declarations and empty traits emit no
* executable bytecode).
*
* @var array<string, list<string>>
*/
// Source file → declared class names. Built incrementally as classes are autoloaded.
// Used to walk the interface/trait/parent hierarchy which coverage drivers miss
// (interfaces and empty traits emit no executable bytecode).
/** @var array<string, list<string>> */
private array $fileToClassNames = [];
/**
* Names already folded into `$fileToClassNames`. Lets the
* incremental refresher skip classes seen in a previous test.
*
* @var array<string, true>
*/
/** @var array<string, true> */
private array $indexedClassNames = [];
/**
* Cached "files this class transitively depends on (interfaces,
* traits, parent chain, parents' interfaces and traits)" for
* project-local class names. Avoids re-walking the same
* hierarchy on every test that touches the same class.
*
* @var array<string, list<string>>
*/
/** @var array<string, list<string>> */
private array $classDependencyCache = [];
/**
* Cached test-file import resolution.
*
* @var array<string, list<string>>
*/
/** @var array<string, list<string>> */
private array $testImportFileCache = [];
/**
* Included-file snapshot captured at the start of the current test.
*
* @var array<string, true>
*/
/** @var array<string, true> */
private array $includedFilesAtTestStart = [];
private bool $active = false;
@ -148,15 +78,8 @@ final class Recorder
$this->driver = 'pcov';
$this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Xdebug 3+ exposes the active mode set via `xdebug_info`,
// so we can ask directly instead of probing with a
// start/stop pair. The probe approach used to emit
// E_WARNING when coverage mode was off; with monitoring
// agents (Sentry, Bugsnag) hooked into the error
// 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.
// Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents
// (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent.
$modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) {
@ -201,17 +124,8 @@ final class Recorder
$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.
// Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only
// TestCase) emit no executable bytecode, so the coverage driver never records them.
$this->linkAncestorFiles($className);
$this->linkImportedFiles($file);
@ -235,13 +149,11 @@ final class Recorder
if ($this->driver === 'pcov') {
\pcov\stop();
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive);
$data = \pcov\collect(\pcov\all);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
// `true` resets Xdebug's internal buffer so the next `start()`
// does not accumulate earlier tests' coverage into the current
// one — otherwise the graph becomes progressively polluted.
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
\xdebug_stop_code_coverage(true);
}
@ -258,29 +170,14 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// Walk each covered class's interfaces / traits / parent chain
// and link those files explicitly. Interface declarations have
// no executable bytecode, so coverage drivers never emit lines
// for them — without this walk, a signature change to an
// interface like `Viewable` would leave the cached results of
// every test that exercises an implementing class stale,
// because the interface file never enters the graph through
// the coverage path.
// Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
// so a signature change would leave implementing-class tests stale without this walk.
$this->linkSourceDependencies(array_keys($data));
$this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
}
/**
* Records an extra source-file dependency for the currently-running
* test. Used by collaborators that capture edges the coverage driver
* cannot see — Blade templates rendered through Laravel's view
* factory are the motivating case (their `.blade.php` source never
* executes directly; a cached compiled PHP file does). No-op when
* the recorder is inactive or no test is in flight, so callers can
* fire it unconditionally from app-level hooks.
*/
public function linkSource(string $sourceFile): void
{
if (! $this->active) {
@ -298,12 +195,7 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
/**
* Records source dependencies for a specific test file. Used for edges
* captured before `Prepared` has opened the normal per-test recorder window.
*
* @param iterable<int, string> $sourceFiles
*/
/** @param iterable<int, string> $sourceFiles */
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
@ -323,23 +215,7 @@ final class Recorder
}
}
/**
* For each project-local source file the coverage driver
* captured for this test, finds the classes / interfaces / traits
* declared in it and links every file in their declarative
* hierarchy: implemented interfaces (transitive), used traits,
* and parent classes (with their own interfaces and traits).
*
* Coverage drivers only record executable lines, so an interface
* signature change (e.g. adding a return type to a `Viewable`
* method) never registers — the interface file has no bytecode
* to instrument. Without this walk, every class implementing the
* interface would silently keep its stale cached result through
* the change, even though `--parallel` (no TIA) catches the
* incompatibility immediately.
*
* @param array<int, string> $coveredFiles absolute paths from coverage
*/
/** @param array<int, string> $coveredFiles */
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
@ -361,15 +237,6 @@ final class Recorder
}
}
/**
* Incrementally folds every project-local class / interface /
* trait declared since the last refresh into `$fileToClassNames`.
* PHP only ever appends to its declared-symbol lists (classes
* never get unloaded), so iterating from `$indexedClassNames`'s
* cardinality forward is sufficient — and over a long suite this
* is dominated by the first test, since most classes are loaded
* by then.
*/
private function refreshClassMap(): void
{
$names = array_merge(
@ -384,12 +251,6 @@ final class Recorder
}
$this->indexedClassNames[$name] = true;
// Names came directly from `get_declared_*`, so the
// class/interface/trait is guaranteed loaded — but
// `class_exists($name, false)` (no autoload) keeps the
// string narrowed to `class-string` for static analysis
// and the `ReflectionClass` constructor stays in its
// documented happy path.
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
@ -416,15 +277,7 @@ final class Recorder
}
}
/**
* Returns the project-local files the named class declaratively
* depends on: implemented interfaces (transitive), used traits,
* and the entire parent chain (each with their own interfaces
* and traits). Cached per class because the answer is invariant
* across a single process.
*
* @return list<string>
*/
/** @return list<string> */
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
@ -458,19 +311,11 @@ final class Recorder
$files[$f] = true;
};
// `getInterfaceNames()` is transitive — it returns interfaces
// from parent classes and parent interfaces too — so a single
// pass covers the whole interface graph.
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
// Direct + ancestor traits. `getTraitNames()` doesn't recurse
// into traits-using-traits, but that's a rare pattern in
// application code; if a project genuinely needs it, the
// coverage driver will pick up the executed bytecode of the
// outer trait and the dependency walk runs against the
// resulting class anyway.
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
@ -490,16 +335,6 @@ final class Recorder
return $this->classDependencyCache[$className] = array_keys($files);
}
/**
* 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)) {
@ -524,11 +359,6 @@ final class Recorder
}
}
/**
* Links project-local classes imported by the test file. This catches
* declaration-only support classes / enums / interfaces that may never emit
* executable coverage lines, and avoids relying on global autoload timing.
*/
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
@ -653,14 +483,6 @@ final class Recorder
return null;
}
/**
* 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)) {
@ -692,14 +514,6 @@ final class Recorder
return $this->classUsesDatabaseCache[$className] = false;
}
/**
* Records that the currently-running test queried `$table`. Called
* by `TableTracker` for every DML statement Laravel's `DB::listen`
* reports; the table name has already been extracted by
* `TableExtractor::fromSql()` so we just store it. No-op outside
* a test window, so the callback is safe to leave armed across
* setUp / tearDown boundaries.
*/
public function linkTable(string $table): void
{
if (! $this->active) {
@ -717,15 +531,6 @@ final class Recorder
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
}
/**
* Records that the currently-running test server-side-rendered the
* named Inertia component. The name is whatever
* `Inertia::render($component, …)` was called with — typically a
* slash-separated path like `Users/Show` that maps to
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
* so the underlying listener can stay armed without leaking
* state between tests.
*/
public function linkInertiaComponent(string $component): void
{
if (! $this->active) {
@ -743,9 +548,7 @@ final class Recorder
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
}
/**
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
*/
/** @return array<string, array<int, string>> */
public function perTestFiles(): array
{
$out = [];
@ -757,9 +560,7 @@ final class Recorder
return $out;
}
/**
* @return array<string, array<int, string>> absolute test file → sorted list of table names.
*/
/** @return array<string, array<int, string>> */
public function perTestTables(): array
{
$out = [];
@ -773,9 +574,7 @@ final class Recorder
return $out;
}
/**
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
*/
/** @return array<string, array<int, string>> */
public function perTestInertiaComponents(): array
{
$out = [];
@ -789,9 +588,7 @@ final class Recorder
return $out;
}
/**
* @return array<string, true> absolute test file → true for tests using a Laravel DB-resetting trait.
*/
/** @return array<string, true> */
public function perTestUsesDatabase(): array
{
return $this->perTestUsesDatabase;
@ -817,17 +614,8 @@ final class Recorder
return null;
}
/**
* Resolves the file that *defines* the test class.
*
* Order of preference:
* 1. Pest's generated `$__filename` static — the original `*.php` file
* containing the `test()` calls (the eval'd class itself has no file).
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
* is intentionally more specific than `ReflectionMethod::getFileName()`
* (which would return the *trait* file for methods brought in via
* `uses SharedTestBehavior`).
*/
// Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName()
// (which returns the trait file for methods brought in via `uses SharedTestBehavior`).
private function readPestFilename(string $className): ?string
{
if (! class_exists($className, false)) {
@ -853,11 +641,6 @@ final class Recorder
return is_string($file) ? $file : null;
}
/**
* Clears all captured state. Useful for long-running hosts (daemons,
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
* — without this, coverage from run N would bleed into run N+1.
*/
public function reset(): void
{
$this->currentTestFile = null;

View File

@ -14,17 +14,20 @@ namespace Pest\Plugins\Tia;
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?string $currentTestFile = null;
private ?float $startTime = null;
public function testPrepared(string $testId): void
public function testPrepared(string $testId, ?string $testFile = null): void
{
$this->currentTestId = $testId;
$this->currentTestFile = $testFile;
$this->startTime = microtime(true);
}
@ -83,7 +86,7 @@ final class ResultCollector
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
public function all(): array
{
@ -102,7 +105,7 @@ final class ResultCollector
* workers) into this collector so the parent can persist them in the same
* snapshot pass as non-parallel runs.
*
* @param array<string, array{status: int, message: string, time: float, assertions: int}> $results
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/
public function merge(array $results): void
{
@ -115,6 +118,7 @@ final class ResultCollector
{
$this->results = [];
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
@ -126,6 +130,7 @@ final class ResultCollector
public function finishTest(): void
{
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
@ -151,5 +156,9 @@ final class ResultCollector
'time' => $time,
'assertions' => $existing['assertions'] ?? 0,
];
if ($this->currentTestFile !== null) {
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
}
}
}

View File

@ -51,6 +51,53 @@ final class Storage
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
}
/**
* Wipes the on-disk state directory for `$projectRoot`. Called by
* `--fresh` so a rebuild starts from a truly empty cache: no stale
* baseline, no leftover worker partials, no fingerprint, no JS
* module cache. Subsequent writes recreate the directory on demand.
*
* Per-project (project key is part of the path) — sibling projects'
* caches under `~/.pest/tia/` are untouched.
*/
public static function purge(string $projectRoot): void
{
$dir = self::tempDir($projectRoot);
if (! is_dir($dir)) {
return;
}
self::removeRecursive($dir);
}
private static function removeRecursive(string $dir): void
{
$entries = @scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) {
self::removeRecursive($path);
continue;
}
@unlink($path);
}
@rmdir($dir);
}
/**
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
* Windows. Returns null if neither resolves to an existing

View File

@ -45,6 +45,21 @@ final readonly class Browser implements WatchDefault
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js',
'public/build/**/*.css',
// Static public assets can affect browser-rendered pages without
// any PHP file changing (favicons, robots, images, downloaded
// manifests, etc.). Only browser-test targets are invalidated.
'public/**/*.js',
'public/**/*.css',
'public/**/*.svg',
'public/**/*.png',
'public/**/*.jpg',
'public/**/*.jpeg',
'public/**/*.webp',
'public/**/*.ico',
'public/**/*.txt',
'public/**/*.json',
'public/**/*.xml',
'public/hot',
];
$patterns = [];

View File

@ -54,8 +54,28 @@ final readonly class Laravel implements WatchDefault
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath],
// Project fixture data. Laravel apps often keep fake repository
// lockfiles / API payloads here and read them via `storage_path()`
// + `file_get_contents()`, which neither PHP coverage nor static
// import edges can observe.
'storage/fixtures/**/*' => [$testPath],
// Non-PHP templates/data living beside app code. These are often
// read dynamically by services (Dockerfile templates, stubs,
// payload examples) and never appear in coverage because PHP only
// sees the reader method, not the external file.
'app/**/*.tpl' => [$testPath],
'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath],
'app/**/*.yaml' => [$testPath],
'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath],
// Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$testPath],
// Mail / view-adjacent themes can be read dynamically by
// mailables (for example Laravel's markdown mail theme CSS).
'resources/views/**/*.css' => [$testPath],
// Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$testPath],

View File

@ -58,12 +58,12 @@ final readonly class Php implements WatchDefault
// suite.
$testPath.'/Datasets/**/*.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],
$testPath.'/Fixtures/**/*.csv' => [$testPath],
$testPath.'/Fixtures/**/*.xml' => [$testPath],
$testPath.'/Fixtures/**/*.txt' => [$testPath],
// Test fixtures — data/source snippets consumed by assertions or
// external analysers. Nested `Fixtures/` directories are common
// beside a single test class, and PHP fixtures may be parsed by
// tools without being `require`d, so coverage cannot see them.
$testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.

View File

@ -43,6 +43,12 @@ final class WatchPatterns
*/
private array $patterns = [];
private bool $enabled = false;
private bool $locally = false;
private bool $filtered = false;
/**
* Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles
@ -149,9 +155,42 @@ final class WatchPatterns
return $affected;
}
public function markEnabled(): void
{
$this->enabled = true;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function markLocally(): void
{
$this->locally = true;
}
public function isLocally(): bool
{
return $this->locally;
}
public function markFiltered(): void
{
$this->filtered = true;
}
public function isFiltered(): bool
{
return $this->filtered;
}
public function reset(): void
{
$this->patterns = [];
$this->enabled = false;
$this->locally = false;
$this->filtered = false;
}
/**

View File

@ -29,7 +29,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->testPrepared($test->className().'::'.$test->methodName());
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
}
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph;
/**
* Accepts a test file only if it is in the TIA-computed affected set.
*
* Falls back to accepting when the graph has no record of the file (new tests
* must always run) or when the file is outside the project root.
*
* @internal
*/
final readonly class TiaTestCaseFilter implements TestCaseFilter
{
/**
* @param array<string, true> $affectedTestFiles Keys are project-relative test file paths.
*/
public function __construct(
private string $projectRoot,
private Graph $graph,
private array $affectedTestFiles,
) {}
public function accept(string $testCaseFilename): bool
{
$rel = $this->relative($testCaseFilename);
if ($rel === null) {
return true;
}
if (! $this->graph->knowsTest($rel)) {
return true;
}
return isset($this->affectedTestFiles[$rel]);
}
private function relative(string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}