This commit is contained in:
nuno maduro
2026-05-01 22:36:15 +01:00
parent 53db68e005
commit bf48e20880
35 changed files with 21 additions and 923 deletions

6
pint.json Normal file
View File

@ -0,0 +1,6 @@
{
"preset": "laravel",
"rules": {
"Pint/phpdoc_type_annotations_only": true
}
}

View File

@ -10,8 +10,6 @@ namespace Pest\Contracts;
interface Restarter interface Restarter
{ {
/** /**
* Re-execs the PHP process when conditions warrant it.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
*/ */
public function maybeRestart(string $projectRoot, array $arguments): void; public function maybeRestart(string $projectRoot, array $arguments): void;

View File

@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{ {
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void public function render(OutputInterface $output): void
{ {
$output->writeln([ $output->writeln([
@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc
]); ]);
} }
/**
* The exit code to be used.
*/
public function exitCode(): int public function exitCode(): int
{ {
return 0; return 0;

View File

@ -54,7 +54,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
/** Sentinel dropped by a recording worker without a usable coverage driver. */
private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-'; private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-';
public const string KEY_COVERAGE_CACHE = 'coverage.bin.gz'; public const string KEY_COVERAGE_CACHE = 'coverage.bin.gz';
@ -67,10 +66,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
/** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */
private const string FILTERED_GLOBAL = 'TIA_FILTERED'; private const string FILTERED_GLOBAL = 'TIA_FILTERED';
/** Workers can't detect `--coverage` from their own argv — paratest strips it. */
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
private bool $graphWritten = false; private bool $graphWritten = false;
@ -109,10 +106,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private bool $forceRefetch = false; private bool $forceRefetch = false;
/** Prevents fetching the same stale baseline twice after structural drift. */
private bool $baselineFetchAttemptedForDrift = false; private bool $baselineFetchAttemptedForDrift = false;
/** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */
private bool $freshRebuild = false; private bool $freshRebuild = false;
private bool $filteredMode = false; private bool $filteredMode = false;
@ -159,15 +154,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* Predicts whether TIA will activate for this run, *before* the Tia
* plugin's `handleArguments` runs. Mirrors the same gate the plugin
* itself applies: `--tia` on the CLI, or `pest()->tia()->always()`
* (optionally `->locally()`, which is honoured only outside CI).
*
* Used by the restarters in `bin/pest`, which fire after
* `Kernel::boot()` (so `tests/Pest.php` has populated WatchPatterns)
* but before any plugin's `handleArguments` runs.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
*/ */
public static function isEnabledForRun(array $arguments): bool public static function isEnabledForRun(array $arguments): bool
@ -183,10 +169,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return false; return false;
} }
// `locally()` opts out on CI. Environment::name() reflects --ci
// only after Environment's own handleArguments has run, which
// hasn't happened at the restart-decision point — so check argv
// directly here.
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true)); return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
} }
@ -258,8 +240,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
// Always strip TIA-owned flags so they never reach PHPUnit, even when
// TIA is not active for this run.
$arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments); $arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
@ -412,8 +392,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($this->branch, $currentSha); $graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot any currently-dirty files so the first replay run
// doesn't mis-report them as changed. See the series record path.
$graph->setLastRunTree( $graph->setLastRunTree(
$this->branch, $this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
@ -523,10 +501,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* Structural drift → discard graph, return null (caller enters record mode).
* Environmental drift → drop results, keep edges, return updated graph.
* Match → return graph unchanged.
*
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current * @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/ */
private function reconcileFingerprint(Graph $graph, array $current): ?Graph private function reconcileFingerprint(Graph $graph, array $current): ?Graph
@ -594,14 +568,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$fingerprint = Fingerprint::compute($projectRoot); $fingerprint = Fingerprint::compute($projectRoot);
// `--fresh` is meant to be a clean slate: nuke the entire per-project
// state dir up front (graph, baseline, worker partials, fingerprint,
// JS module cache, coverage marker, etc.). Wiping per-key in code
// would leave room for stale entries we forgot about — most
// recently, status-7/8 result entries with no `file` that survived
// a rebuild and kept tripping `hasUnlocatedFailuresOrErrors()` on
// subsequent `--filtered` runs. Safe here because `handleParent`
// runs in the parent before any worker is spawned.
if ($forceRebuild) { if ($forceRebuild) {
Storage::purge($projectRoot); Storage::purge($projectRoot);
} }
@ -624,10 +590,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
} }
// No local graph and not being forced to rebuild from scratch: try
// to pull a team-shared baseline so fresh checkouts (new devs, CI
// containers) don't pay the full record cost. If the pull succeeds
// the graph is re-read and reconciled against the local env.
if (! $graph instanceof Graph if (! $graph instanceof Graph
&& ! $forceRebuild && ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift && ! $this->baselineFetchAttemptedForDrift
@ -643,17 +605,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write(self::KEY_COVERAGE_MARKER, ''); $this->state->write(self::KEY_COVERAGE_MARKER, '');
} }
// Kick off the JS module graph resolver in the background so it
// runs in parallel with the test suite. By the time the flush
// path calls `JsModuleGraph::build()`, the result is usually
// already on stdout and `wait()` returns instantly. Cheap when
// the cache is fresh — the warmer fingerprint-checks first and
// skips spawning Node entirely.
if (! Parallel::isWorker() && JsModuleGraph::isApplicable($projectRoot)) { if (! Parallel::isWorker() && JsModuleGraph::isApplicable($projectRoot)) {
JsModuleGraph::warmInBackground($projectRoot); JsModuleGraph::warmInBackground($projectRoot);
} }
// First `--tia --coverage` run: no cache to merge against yet, must record the full suite.
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
return $this->enterRecordMode($arguments); return $this->enterRecordMode($arguments);
} }
@ -749,12 +704,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* During replay, affected tests execute normally. If a coverage driver is
* available, record those executions too so refactors that introduce new
* dependencies update the graph without requiring a full `--fresh` run.
* Cached tests short-circuit before `Recorder::beginTest()`, so they don't
* produce empty replacement edges.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
* @return array<int, string> * @return array<int, string>
*/ */
@ -820,12 +769,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$failedFromCache = []; $failedFromCache = [];
if ($this->filteredMode) { if ($this->filteredMode) {
// `failedOrErroredTestFiles()` only yields failures that have a
// mapped file — the snapshot path now reflects on the class
// when the collector loses the path, so an unlocated failure
// is no longer expected. If one slips through, doing the best
// we can with the located ones is strictly better than bailing
// to a full suite.
$failedFromCache = $graph->failedOrErroredTestFiles($this->branch); $failedFromCache = $graph->failedOrErroredTestFiles($this->branch);
} }
@ -886,17 +829,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* Surfaces what TIA decided to run and why, before the suite
* starts. Two pieces a developer wants at a glance:
*
* 1. *How many* tests are about to run — the deciding factor for
* whether they wait for the run or kick off something else.
* 2. *Why* — which changed files drove the affected set, and how
* many came in via cached failures (filtered mode).
*
* Stays quiet when nothing is affected: the existing
* `NoAffectedTestsFound` panic / recap line covers that path.
*
* @param array<int, string> $changedFiles * @param array<int, string> $changedFiles
* @param array<int, string> $affectedFromChanges * @param array<int, string> $affectedFromChanges
* @param array<int, string> $failedFromCache * @param array<int, string> $failedFromCache
@ -908,9 +840,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
// Failures that overlap with the change-driven set are already
// pulled in by edges — don't double-count them as a separate
// reason in the breakdown.
$newFailures = $failedFromCache === [] $newFailures = $failedFromCache === []
? 0 ? 0
: count(array_diff($failedFromCache, $affectedFromChanges)); : count(array_diff($failedFromCache, $affectedFromChanges));
@ -954,9 +883,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')', $reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
)); ));
// List the first few affected test files so the developer can see
// *which* tests are about to run, not just the count. Capped at 10
// to keep the line tight on large impact sets.
$previewLimit = 10; $previewLimit = 10;
$sorted = $affected; $sorted = $affected;
sort($sorted); sort($sorted);
@ -1273,8 +1199,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function registerRecap(): void private function registerRecap(): void
{ {
DefaultPrinter::addRecap(function (): string { DefaultPrinter::addRecap(function (): string {
// mergeWorkerReplayPartials fires before addOutput on --parallel, which is intentional:
// partial keys are deleted on read so the later addOutput call becomes a no-op.
if (Parallel::isEnabled() && ! Parallel::isWorker()) { if (Parallel::isEnabled() && ! Parallel::isWorker()) {
$this->mergeWorkerReplayPartials(); $this->mergeWorkerReplayPartials();
} }
@ -1364,13 +1288,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
foreach ($results as $testId => $result) { foreach ($results as $testId => $result) {
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
// The collector occasionally hands us nothing usable: PHPUnit's
// Prepared event can miss the file for Pest-generated classes,
// and an eval'd class path (".../IndexTest.php(1) : eval()'d code")
// would be rejected later by Graph::relative(). Recover the real
// path from the class embedded in the test ID — without it,
// filtered runs lose the ability to re-run only the failing test
// next time.
if ($file === null || str_contains($file, "eval()'d")) { if ($file === null || str_contains($file, "eval()'d")) {
$file = $this->resolveFailedTestFile($testId); $file = $this->resolveFailedTestFile($testId);
} }
@ -1390,22 +1307,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$collector->reset(); $collector->reset();
} }
/**
* Resolves the source file for a Pest-generated test class.
*
* Pest synthesises a per-test class via `eval()` and writes the
* original test file path to a `private static $__filename` property
* (see `src/Factories/TestCaseFactory.php`). Reflecting on the class
* with `getFileName()` would return the eval'd location, which
* `Graph::relative()` rejects — losing the file mapping.
*
* Strategy:
* 1. Read the `__filename` static if the class declares it (Pest
* tests).
* 2. Otherwise use `getFileName()` and skip eval'd frames by
* walking up the parent class chain — a plain PHPUnit test
* lives in a real file at the top of that chain.
*/
private function resolveFailedTestFile(string $testId): ?string private function resolveFailedTestFile(string $testId): ?string
{ {
$class = strstr($testId, '::', true); $class = strstr($testId, '::', true);
@ -1456,11 +1357,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* PHP source changes can introduce new dependencies. Without a coverage
* driver, replay can run the currently affected tests but cannot refresh
* the graph, so a later edit to the newly introduced dependency could be
* missed. Treat those runs as full-suite unless coverage can self-heal.
*
* @param array<int, string> $changedFiles * @param array<int, string> $changedFiles
*/ */
private function hasProjectPhpSourceChanges(array $changedFiles): bool private function hasProjectPhpSourceChanges(array $changedFiles): bool

View File

@ -26,20 +26,10 @@ final readonly class BaselineSync
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
// Subdirectory under the per-project state dir (`~/.pest/tia/<project>/`)
// 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_DIR = 'artifacts'; private const string DOWNLOAD_CACHE_DIR = '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; 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; private const int FETCH_COOLDOWN_SECONDS = 86400;
public function __construct( public function __construct(
@ -78,11 +68,6 @@ final readonly class BaselineSync
$payload = $this->download($repo, $projectRoot, $failureKind); $payload = $this->download($repo, $projectRoot, $failureKind);
if ($payload === null) { if ($payload === null) {
// Genuine "no baseline published yet" → cool down and show
// the publish-instructions YAML so the user can wire CI.
// Anything else (missing gh, auth, network, mid-download
// error) is transient and gets a one-line diagnostic
// instead — no cooldown, no noisy YAML.
if ($failureKind === 'no-runs' || $failureKind === null) { if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown(); $this->startCooldown();
$this->emitPublishInstructions($repo); $this->emitPublishInstructions($repo);
@ -169,8 +154,6 @@ final readonly class BaselineSync
$this->renderDetail('To share the baseline with your team, add this workflow to the repo:'); $this->renderDetail('To share the baseline with your team, add this workflow to the repo:');
$this->renderDetail('.github/workflows/tia-baseline.yml'); $this->renderDetail('.github/workflows/tia-baseline.yml');
// YAML stays as a raw indented block — Termwind would mangle the
// verbatim whitespace.
$indentedYaml = array_map( $indentedYaml = array_map(
static fn (string $line): string => ' '.$line, static fn (string $line): string => ' '.$line,
explode("\n", $yaml), explode("\n", $yaml),
@ -182,7 +165,6 @@ final readonly class BaselineSync
$this->renderDetail('Details: https://pestphp.com/docs/tia/ci'); $this->renderDetail('Details: https://pestphp.com/docs/tia/ci');
} }
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
private function isCi(): bool private function isCi(): bool
{ {
return getenv('GITHUB_ACTIONS') === 'true' return getenv('GITHUB_ACTIONS') === 'true'
@ -326,9 +308,6 @@ YAML;
if ($listError !== null) { if ($listError !== null) {
$failureKind = $listError['kind']; $failureKind = $listError['kind'];
// Tier 1 — actionable misconfiguration. Stop the suite and
// tell the user what to fix; a silent fall-through to a
// full record would just paper over the bug.
if (in_array($failureKind, ['forbidden', 'not-found'], true)) { if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
sprintf('Failed to query baseline runs — %s', $listError['message']), sprintf('Failed to query baseline runs — %s', $listError['message']),
@ -336,8 +315,6 @@ YAML;
)); ));
} }
// Tier 2 — transient (network, rate-limit, unknown). Surface
// the diagnostic but let the suite fall through to record mode.
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s', 'Failed to query baseline runs — %s',
$listError['message'], $listError['message'],
@ -347,7 +324,6 @@ YAML;
} }
if ($runId === null) { if ($runId === null) {
// Genuine missing baseline — caller emits publish instructions.
$failureKind = 'no-runs'; $failureKind = 'no-runs';
return null; return null;
@ -355,12 +331,7 @@ YAML;
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId); $runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
// 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)) { 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); @touch($runCacheDir);
$this->renderBadge('INFO', sprintf( $this->renderBadge('INFO', sprintf(
@ -414,7 +385,6 @@ YAML;
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$failureKind = $diagnosis['kind']; $failureKind = $diagnosis['kind'];
// Tier 1 — actionable. Stop hard with a clear diagnostic.
if (in_array($failureKind, ['forbidden', 'not-found'], true)) { if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
sprintf('Baseline download failed — %s', $diagnosis['message']), sprintf('Baseline download failed — %s', $diagnosis['message']),
@ -422,7 +392,6 @@ YAML;
)); ));
} }
// Tier 2 — transient. Diagnostic + fall through to record mode.
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'Baseline download failed — %s', 'Baseline download failed — %s',
$diagnosis['message'], $diagnosis['message'],
@ -436,9 +405,6 @@ YAML;
if ($payload === null) { if ($payload === null) {
$this->cleanup($runCacheDir); $this->cleanup($runCacheDir);
// Artifact present but malformed — CI's publish step is
// broken. Falling through would silently waste the next
// run; surface the bug instead.
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).', 'Baseline downloaded but the artifact is missing expected files (graph.json).',
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.', 'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
@ -450,11 +416,6 @@ YAML;
return $payload; return $payload;
} }
/**
* Looks up the artifact's compressed size so the progress bar has a
* denominator. Returns null on any failure — callers fall back to a
* size-less spinner.
*/
private function artifactSize(string $repo, string $runId): ?int private function artifactSize(string $repo, string $runId): ?int
{ {
$process = new Process([ $process = new Process([
@ -484,11 +445,6 @@ YAML;
$speed = (int) ($current / $elapsed); $speed = (int) ($current / $elapsed);
if ($totalBytes !== null && $totalBytes > 0) { if ($totalBytes !== null && $totalBytes > 0) {
// gh extracts as it downloads, so disk size can briefly exceed
// the compressed `size_in_bytes` for multi-file artifacts. Cap
// the percentage at 99% until the process actually exits — the
// cleared line + completion message take care of the final
// "100%" message naturally.
$percent = min(99, (int) floor(($current / $totalBytes) * 100)); $percent = min(99, (int) floor(($current / $totalBytes) * 100));
$message = sprintf( $message = sprintf(
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)', ' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
@ -505,8 +461,6 @@ YAML;
); );
} }
// \r returns to start of line, \033[K erases from cursor to end —
// safe regardless of message length, no ANSI-aware padding needed.
$this->output->write("\r\033[K".$message); $this->output->write("\r\033[K".$message);
} }
@ -565,11 +519,6 @@ YAML;
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR; return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
} }
/**
* 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 private function safeRunId(string $runId): string
{ {
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? ''; $sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
@ -577,13 +526,6 @@ YAML;
return $sanitised === '' ? 'unknown' : $sanitised; 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 private function trimDownloadCache(string $projectRoot): void
{ {
$root = $this->downloadCacheDir($projectRoot); $root = $this->downloadCacheDir($projectRoot);
@ -632,12 +574,6 @@ YAML;
} }
/** /**
* Returns `[runId|null, errorOrNull]`. Distinguishes "no runs yet"
* (runId null, error null) from "couldn't ask GitHub" (error
* populated with kind + message). Lets the caller pick between
* showing publish instructions and emitting a transient-failure
* diagnostic.
*
* @return array{0: ?string, 1: ?array{kind: string, message: string}} * @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/ */
private function latestSuccessfulRunIdWithError(string $repo): array private function latestSuccessfulRunIdWithError(string $repo): array
@ -673,10 +609,6 @@ YAML;
} }
/** /**
* Maps a chunk of `gh` stderr/stdout to a coarse kind + a short,
* actionable message. Falls back to the first non-empty line of
* the output so even unrecognised errors aren't reduced to "unknown".
*
* @return array{kind: string, message: string} * @return array{kind: string, message: string}
*/ */
private function classifyGhError(string $output): array private function classifyGhError(string $output): array
@ -722,8 +654,6 @@ YAML;
]; ];
} }
// Unknown — surface the first informative line so the user has
// *something* to act on.
$message = trim(strtok($output, "\n")); $message = trim(strtok($output, "\n"));
return ['kind' => 'unknown', 'message' => $message]; return ['kind' => 'unknown', 'message' => $message];

View File

@ -22,11 +22,7 @@ final readonly class Bootstrapper implements BootstrapperContract
} }
/** /**
* TIA's per-project state directory. Default layout is
* `~/.pest/tia/<project-key>/` so the graph survives `composer
* install`, stays out of the project tree, and is naturally shared
* across worktrees of the same repo. See {@see Storage} for the key * across worktrees of the same repo. See {@see Storage} for the key
* derivation and the home-dir-missing fallback.
*/ */
private function tempDir(): string private function tempDir(): string
{ {

View File

@ -24,7 +24,6 @@ final readonly class ChangedFiles
return $files; return $files;
} }
// Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
$candidates = array_fill_keys($files, true); $candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) { foreach (array_keys($lastRunTree) as $snapshotted) {
@ -45,8 +44,6 @@ final readonly class ChangedFiles
} }
if (! $exists) { if (! $exists) {
// 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; $remaining[] = $file;
continue; continue;
@ -71,10 +68,6 @@ final readonly class ChangedFiles
} }
/** /**
* Computes content hashes for the given project-relative files. Used to
* snapshot the working tree after a successful run so the next run can
* detect which files are actually different.
*
* @param array<int, string> $files * @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash * @return array<string, string> path → xxh128 content hash
*/ */
@ -86,9 +79,6 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Record the deletion with an empty-string sentinel so the
// next run recognises "still deleted" as unchanged rather
// than re-flagging the file as a fresh change.
$out[$file] = ''; $out[$file] = '';
continue; continue;
@ -106,8 +96,6 @@ final readonly class ChangedFiles
/** /**
* @return array<int, string>|null `null` when git is unavailable, or when * @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push).
*/ */
public function since(?string $sha): ?array public function since(?string $sha): ?array
{ {
@ -127,9 +115,6 @@ final readonly class ChangedFiles
$files = array_merge($files, $this->workingTreeChanges()); $files = array_merge($files, $this->workingTreeChanges());
// Normalise + dedupe, filtering out paths that can never belong to the
// graph: vendor (caught by the fingerprint instead), cache dirs, and
// anything starting with a dot we don't care about.
$unique = []; $unique = [];
foreach ($files as $file) { foreach ($files as $file) {
@ -144,13 +129,6 @@ final readonly class ChangedFiles
$candidates = array_keys($unique); $candidates = array_keys($unique);
// Behavioural de-noising: for every file git calls "changed", hash
// the current content and the content at `$sha` through
// `ContentHash::of()`. A change that only touched comments /
// whitespace / blade `{{-- --}}` blocks produces the same hash on
// both sides and gets dropped before it can invalidate any test.
// Without this, a single-comment edit on a migration re-runs the
// entire DB-touching suite.
if ($sha !== null && $sha !== '') { if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha); return $this->filterBehaviourallyUnchanged($candidates, $sha);
} }
@ -170,7 +148,6 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Deleted on disk — a genuine change, keep it.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -187,8 +164,6 @@ final readonly class ChangedFiles
$baselineContent = $this->contentAtSha($sha, $file); $baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) { if ($baselineContent === null) {
// Couldn't read the baseline (new file, binary, `git show`
// failed). Err on the side of re-running.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -204,12 +179,6 @@ final readonly class ChangedFiles
return $remaining; return $remaining;
} }
/**
* Reads `$path` at `$sha` via `git show`. Returns null when the file
* didn't exist at that SHA, when git errors, or when the content
* isn't valid UTF-8-safe bytes (rare — binary files that happen to
* be tracked).
*/
private function contentAtSha(string $sha, string $path): ?string private function contentAtSha(string $sha, string $path): ?string
{ {
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
@ -231,10 +200,6 @@ final readonly class ChangedFiles
'.phpunit.result.cache', '.phpunit.result.cache',
'vendor/', 'vendor/',
'node_modules/', 'node_modules/',
// Laravel regenerates these from manifest state
// (package.json, service providers) at boot — they're
// fully derived, not authored. Treating them as
// "changes" just flaps the diff noisily.
'bootstrap/cache/', 'bootstrap/cache/',
]; ];
@ -281,9 +246,6 @@ final readonly class ChangedFiles
); );
$process->run(); $process->run();
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
// "unreachable" and force a rebuild.
return $process->getExitCode() === 0; return $process->getExitCode() === 0;
} }
@ -310,14 +272,6 @@ final readonly class ChangedFiles
*/ */
private function workingTreeChanges(): array private function workingTreeChanges(): array
{ {
// `-z` produces NUL-terminated records with no path quoting, so paths
// that contain spaces, tabs, unicode or other special characters
// are passed through verbatim. Without `-z`, git wraps such paths in
// quotes with backslash escapes, which would corrupt our lookup keys.
//
// Record format: `XY <SP> <path> <NUL>` for most entries, and
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
// fields).
$process = new Process( $process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'], ['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot, $this->projectRoot,
@ -348,8 +302,6 @@ final readonly class ChangedFiles
$status = substr($record, 0, 2); $status = substr($record, 0, 2);
$path = substr($record, 3); $path = substr($record, 3);
// Renames/copies emit two records: the new path first, then the
// original. Consume both.
if ($status[0] === 'R' || $status[0] === 'C') { if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path; $files[] = $path;

View File

@ -12,8 +12,6 @@ use Pest\Support\Container;
final class Configuration final class Configuration
{ {
/** /**
* Activates TIA for every run without requiring the `--tia` CLI flag.
*
* @return $this * @return $this
*/ */
public function always(): self public function always(): self
@ -26,10 +24,6 @@ final class Configuration
} }
/** /**
* 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 * @return $this
*/ */
public function locally(): self public function locally(): self
@ -43,10 +37,6 @@ final class Configuration
} }
/** /**
* 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 * @return $this
*/ */
public function filtered(): self public function filtered(): self
@ -59,9 +49,6 @@ final class Configuration
} }
/** /**
* Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults.
*
* @param array<string, string> $patterns glob → project-relative test dir * @param array<string, string> $patterns glob → project-relative test dir
* @return $this * @return $this
*/ */

View File

@ -9,11 +9,6 @@ namespace Pest\Plugins\Tia;
*/ */
final class ContentHash final class ContentHash
{ {
/**
* xxh128 hex of the file's "behavioural" shape, or `false` when the
* file can't be read. Callers should treat `false` the same way they
* treated a failed `hash_file()` previously.
*/
public static function of(string $absolute): string|false public static function of(string $absolute): string|false
{ {
$raw = @file_get_contents($absolute); $raw = @file_get_contents($absolute);
@ -25,11 +20,6 @@ final class ContentHash
return self::ofContent($absolute, $raw); return self::ofContent($absolute, $raw);
} }
/**
* Same as `of()` but accepts the file contents in memory. Used when
* we already have the bytes (e.g. from `git show <sha>:<path>`) and
* want to avoid a disk round-trip.
*/
public static function ofContent(string $path, string $raw): string public static function ofContent(string $path, string $raw): string
{ {
$lower = strtolower($path); $lower = strtolower($path);
@ -51,13 +41,6 @@ final class ContentHash
return hash('xxh128', $raw); return hash('xxh128', $raw);
} }
/**
* Tokenise the content and hash the concatenated values of every
* token except whitespace / comment / docblock. `token_get_all()`
* is built-in, fast, and enough to collapse any formatting-only
* edit. If tokenisation fails (rare syntax error), fall back to
* the raw hash so the caller still gets a deterministic signal.
*/
private static function hashPhpContent(string $raw): string private static function hashPhpContent(string $raw): string
{ {
$tokens = @token_get_all($raw); $tokens = @token_get_all($raw);
@ -88,14 +71,6 @@ final class ContentHash
return hash('xxh128', $normalised); return hash('xxh128', $normalised);
} }
/**
* Blade templates aren't PHP syntactically, so `token_get_all()`
* doesn't help. Strip `{{-- … --}}` comments (the only Blade-native
* comment form) and collapse whitespace runs. Output differences
* that would survive the Blade compiler (markup reordering, new
* directives, changed interpolation) still flip the hash; pure
* reformatting does not.
*/
private static function hashBladeContent(string $raw): string private static function hashBladeContent(string $raw): string
{ {
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw; $stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
@ -104,17 +79,6 @@ final class ContentHash
return hash('xxh128', trim($stripped)); return hash('xxh128', trim($stripped));
} }
/**
* Conservative JS/TS/Vue/Svelte normaliser. Strips `//` line
* comments and `/* … *\/` block comments that appear on their own
* lines (including leading indentation), then collapses
* whitespace. Deliberately leaves trailing comments after code
* alone — a string literal like `'http://foo'` would be unsafe to
* split on `//` without a full lexer. The direction of error is
* over-detection (we may not strip a trailing comment that's
* purely cosmetic), never under-detection. Blank lines and
* indentation changes are erased regardless.
*/
private static function hashJsContent(string $raw): string private static function hashJsContent(string $raw): string
{ {
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw; $stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;

View File

@ -9,33 +9,15 @@ namespace Pest\Plugins\Tia\Contracts;
*/ */
interface State interface State
{ {
/**
* Returns the stored blob for `$key`, or `null` when the key is unset
* or cannot be read.
*/
public function read(string $key): ?string; public function read(string $key): ?string;
/**
* Atomically stores `$content` under `$key`. Existing value (if any) is
* replaced. Implementations SHOULD guarantee that concurrent readers
* never observe partial writes.
*/
public function write(string $key, string $content): bool; public function write(string $key, string $content): bool;
/**
* Removes `$key`. Returns true whether or not the key existed beforehand
* — callers should treat a `true` result as "the key is now absent",
* not "the key was present and has been removed."
*/
public function delete(string $key): bool; public function delete(string $key): bool;
public function exists(string $key): bool; public function exists(string $key): bool;
/** /**
* Returns every key whose name starts with `$prefix`. Used to collect
* paratest worker partials (`worker-edges-<token>.json`, etc.) without
* exposing backend-specific glob semantics.
*
* @return list<string> * @return list<string>
*/ */
public function keysWithPrefix(string $prefix): array; public function keysWithPrefix(string $prefix): array;

View File

@ -14,19 +14,11 @@ use Throwable;
final class CoverageCollector final class CoverageCollector
{ {
/** /**
* Cached `className → test file` lookups. Class reflection is cheap
* individually but the record run can visit tens of thousands of
* samples, so the cache matters.
*
* @var array<string, string|null> * @var array<string, string|null>
*/ */
private array $classFileCache = []; private array $classFileCache = [];
/** /**
* Rebuilds the same `absolute test file → list<absolute source file>`
* shape that `Recorder::perTestFiles()` exposes, so callers can treat
* the two collectors interchangeably when feeding the graph.
*
* @return array<string, array<int, string>> * @return array<string, array<int, string>>
*/ */
public function perTestFiles(): array public function perTestFiles(): array
@ -48,9 +40,6 @@ final class CoverageCollector
$edges = []; $edges = [];
foreach ($lineCoverage as $sourceFile => $lines) { foreach ($lineCoverage as $sourceFile => $lines) {
// Collect the set of tests that hit any line in this file once,
// then emit one edge per (testFile, sourceFile) pair. Walking
// the lines per test would re-resolve the test file repeatedly.
$testIds = []; $testIds = [];
foreach ($lines as $hits) { foreach ($lines as $hits) {
@ -90,9 +79,6 @@ final class CoverageCollector
private function testIdToFile(string $testId): ?string private function testIdToFile(string $testId): ?string
{ {
// PHPUnit's test id is `ClassName::methodName` with an optional
// `#dataSetName` suffix for data-provider runs. Strip the dataset
// part — we only need the class.
$hash = strpos($testId, '#'); $hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash); $identifier = $hash === false ? $testId : substr($testId, 0, $hash);
@ -120,9 +106,6 @@ final class CoverageCollector
$reflection = new ReflectionClass($className); $reflection = new ReflectionClass($className);
// Pest's eval'd test classes expose the original `.php` path on a
// static `$__filename`. The eval'd class itself has no file of its
// own, so prefer this property when present.
if ($reflection->hasProperty('__filename')) { if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename'); $property = $reflection->getProperty('__filename');

View File

@ -28,9 +28,6 @@ final class CoverageMerger
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) { if ($cachedBytes === null) {
// First `--tia --coverage` run: nothing cached yet, so the
// current file already represents the full suite. Capture it
// verbatim (as serialised bytes) for next time.
$current = self::requireCoverage($reportPath); $current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) { if ($current instanceof CodeCoverage) {
@ -61,8 +58,6 @@ final class CoverageMerger
$serialised = serialize($cached); $serialised = serialize($cached);
// Write back to the PHPUnit-style `.cov` path so the report reader
// can `require` it, and to the state cache for the next run.
@file_put_contents( @file_put_contents(
$reportPath, $reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n", '<?php return unserialize('.var_export($serialised, true).");\n",
@ -84,12 +79,6 @@ final class CoverageMerger
return $decoded === false ? null : $decoded; return $decoded === false ? null : $decoded;
} }
/**
* Removes from `$cached`'s per-line test attribution any test id that
* appears in `$current`. Those tests just ran, so the fresh slice is
* authoritative — keeping stale attribution in the cache would claim
* a test still covers a line it no longer touches.
*/
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{ {
$currentIds = self::collectTestIds($current); $currentIds = self::collectTestIds($current);

View File

@ -13,13 +13,6 @@ final class BladeEdges
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* App-scoped marker that makes `arm()` idempotent. Tests call it
* from every `setUp()`, and Laravel reuses the same app instance
* across tests in most configurations — without this guard we'd
* stack one composer per test and replay every one of them on
* every view render.
*/
private const string MARKER = 'pest.tia.blade-edges-armed'; private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void public static function arm(Recorder $recorder): void

View File

@ -13,22 +13,8 @@ final class InertiaEdges
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* Event class name used as the listener key. Stored *without* a
* leading backslash because Laravel's `Dispatcher` keys
* `$listeners[$eventName]` by the literal string passed to
* `listen()`, and looks up incoming events by their PHP-class
* name (`get_class($event)`), which never has a leading
* backslash. A `\Illuminate\…` key would silently never match.
*/
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled'; private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
/**
* App-scoped marker that makes `arm()` idempotent across per-test
* `setUp()` calls. Laravel reuses the same app across tests in
* most configurations — without this guard we'd stack one
* listener per test.
*/
private const string MARKER = 'pest.tia.inertia-edges-armed'; private const string MARKER = 'pest.tia.inertia-edges-armed';
public static function arm(Recorder $recorder): void public static function arm(Recorder $recorder): void
@ -87,16 +73,8 @@ final class InertiaEdges
}); });
} }
/**
* Pulls the Inertia component name out of a Laravel response,
* handling both XHR (`X-Inertia` + JSON body) and full HTML
* (`<div id="app" data-page="…">`) shapes. Returns null for any
* non-Inertia response so the caller can ignore it cheaply.
*/
private static function extractComponent(object $response): ?string private static function extractComponent(object $response): ?string
{ {
// XHR path: Inertia sets an `X-Inertia: true` header and the
// body is JSON with a `component` key.
if (property_exists($response, 'headers') && is_object($response->headers)) { if (property_exists($response, 'headers') && is_object($response->headers)) {
$headers = $response->headers; $headers = $response->headers;
@ -117,32 +95,12 @@ final class InertiaEdges
} }
} }
// Initial-load HTML path. Inertia ships two shapes here and
// we honour both:
//
// 1. SSR-safe script tag — `<script data-page="app"
// type="application/json">{…JSON…}</script>`. The
// Laravel React starter kit (and modern Inertia-React)
// use this so the JSON survives server-rendered
// hydration without HTML-encoding the payload into an
// attribute. The `data-page="app"` *attribute value* is
// the literal string `"app"` — only the tag *body*
// carries the page JSON.
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
// Inertia-Vue and Inertia-React still emit this. Here
// `data-page` IS the JSON, HTML-entity-encoded.
//
// Try the script-tag shape first; if the response uses it,
// the classic regex would also see a `data-page="app"` token
// and try to JSON-decode the literal string `"app"`.
$content = self::readContent($response); $content = self::readContent($response);
if ($content === null) { if ($content === null) {
return null; return null;
} }
// Lookahead pair handles arbitrary attribute order on the
// `<script>` tag.
if (str_contains($content, 'type="application/json"') if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) { && preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1])); $component = self::componentFromJson(html_entity_decode($match[1]));
@ -152,9 +110,6 @@ final class InertiaEdges
} }
} }
// Classic: only accept a value that looks like a JSON object
// (`{…}`). Avoids matching the script-tag form's
// `data-page="app"` attribute when both shapes coexist.
if (str_contains($content, 'data-page=') if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) { && preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1])); $component = self::componentFromJson(html_entity_decode($match[1]));
@ -167,12 +122,6 @@ final class InertiaEdges
return null; return null;
} }
/**
* Parses an Inertia page JSON blob and returns the `component`
* field if it's a non-empty string. Used by both the script-tag
* and the `data-page`-attribute paths so the success criteria are
* identical.
*/
private static function componentFromJson(string $json): ?string private static function componentFromJson(string $json): ?string
{ {
/** @var mixed $decoded */ /** @var mixed $decoded */

View File

@ -11,11 +11,6 @@ use Pest\Plugins\Tia\Contracts\State;
*/ */
final readonly class FileState implements State final readonly class FileState implements State
{ {
/**
* Configured root. May not exist on disk yet; resolved + created on
* the first write. Keeping the raw string lets the instance be built
* before Pest's temp dir has been materialised.
*/
private string $rootDir; private string $rootDir;
public function __construct(string $rootDir) public function __construct(string $rootDir)
@ -49,8 +44,6 @@ final readonly class FileState implements State
return false; return false;
} }
// Atomic rename — on POSIX filesystems this is a single-step
// replacement, so concurrent readers never see a half-written file.
if (! @rename($tmp, $path)) { if (! @rename($tmp, $path)) {
@unlink($tmp); @unlink($tmp);
@ -100,22 +93,11 @@ final readonly class FileState implements State
return $keys; return $keys;
} }
/**
* Absolute path for `$key`. Not part of the interface — used by the
* coverage merger and similar callers that need direct filesystem
* access (e.g. `require` on a cached PHP file). Consumers that only
* deal in bytes should go through `read()` / `write()`.
*/
public function pathFor(string $key): string public function pathFor(string $key): string
{ {
return $this->rootDir.DIRECTORY_SEPARATOR.$key; return $this->rootDir.DIRECTORY_SEPARATOR.$key;
} }
/**
* Returns the resolved root if it exists already, otherwise `null`.
* Used by read-side helpers so they don't eagerly create the directory
* just to find nothing inside.
*/
private function resolvedRoot(): ?string private function resolvedRoot(): ?string
{ {
$resolved = @realpath($this->rootDir); $resolved = @realpath($this->rootDir);
@ -123,10 +105,6 @@ final readonly class FileState implements State
return $resolved === false ? null : $resolved; return $resolved === false ? null : $resolved;
} }
/**
* Creates the root dir on demand. Returns false only when creation
* fails and the directory still isn't there afterwards.
*/
private function ensureRoot(): bool private function ensureRoot(): bool
{ {
if (is_dir($this->rootDir)) { if (is_dir($this->rootDir)) {

View File

@ -22,7 +22,6 @@ final readonly class Fingerprint
return [ return [
'structural' => [ 'structural' => [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
// 'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
@ -34,10 +33,6 @@ final readonly class Fingerprint
'composer_json' => self::composerJsonHash($projectRoot), 'composer_json' => self::composerJsonHash($projectRoot),
], ],
'environmental' => [ 'environmental' => [
// 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),
], ],
]; ];
} }
@ -135,7 +130,6 @@ final readonly class Fingerprint
return self::bucket($fingerprint, 'environmental'); return self::bucket($fingerprint, 'environmental');
} }
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
/** /**
* @param array<string, mixed> $fingerprint * @param array<string, mixed> $fingerprint
* @return array<string, mixed> * @return array<string, mixed>

View File

@ -46,7 +46,6 @@ final class Graph
*/ */
private array $baselines = []; private array $baselines = [];
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
private readonly string $projectRoot; private readonly string $projectRoot;
/** @var array<string, true>|null */ /** @var array<string, true>|null */
@ -95,9 +94,6 @@ final class Graph
$affectedSet = []; $affectedSet = [];
// 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 = []; $migrationPaths = [];
$nonMigrationPaths = []; $nonMigrationPaths = [];
@ -142,8 +138,6 @@ final class Graph
} }
} }
// 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 = []; $globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
@ -174,8 +168,6 @@ final class Graph
} }
} }
// 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 = []; $sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
@ -202,9 +194,6 @@ final class Graph
} }
} }
// 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 = []; $newJsFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
@ -229,8 +218,6 @@ final class Graph
$freshMap = JsModuleGraph::buildStrict($this->projectRoot); $freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) { if ($freshMap === null) {
// 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.
View::render('components.badge', [ View::render('components.badge', [
'type' => 'WARN', 'type' => 'WARN',
'content' => sprintf( 'content' => sprintf(
@ -243,7 +230,6 @@ final class Graph
$pages = $freshMap[$rel] ?? []; $pages = $freshMap[$rel] ?? [];
if ($pages === []) { if ($pages === []) {
// Vite confirms no page imports this file — suppress the watch broadcast.
$sharedFilesResolved[$rel] = true; $sharedFilesResolved[$rel] = true;
continue; continue;
@ -280,8 +266,6 @@ final class Graph
} }
} }
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
// their always-on edges from re-running the whole DB suite.
$changedIds = []; $changedIds = [];
$unknownSourceDirs = []; $unknownSourceDirs = [];
$sourcePhpChanged = false; $sourcePhpChanged = false;
@ -301,7 +285,6 @@ final class Graph
$absolute = $this->projectRoot.'/'.$rel; $absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Deleted source file unknown to the graph — no edge ever pointed to it.
continue; continue;
} }
@ -311,8 +294,6 @@ final class Graph
} }
} }
// 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) { if ($sourcePhpChanged) {
foreach (array_keys($this->edges) as $testFile) { foreach (array_keys($this->edges) as $testFile) {
if ($this->isArchTestFile($testFile)) { if ($this->isArchTestFile($testFile)) {
@ -336,7 +317,6 @@ final class Graph
} }
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered // Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
// ancestors and invalidate only tests that covered them.
$staticallyHandledBlade = []; $staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) { if (isset($this->fileIds[$rel])) {
@ -358,13 +338,10 @@ final class Graph
$staticallyHandledBlade[$rel] = true; $staticallyHandledBlade[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) { } elseif ($this->isBladeComponentPath($rel)) {
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
$staticallyHandledBlade[$rel] = true; $staticallyHandledBlade[$rel] = true;
} }
} }
// 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; $unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
@ -378,7 +355,6 @@ final class Graph
} }
if (! isset($this->fileIds[$rel])) { if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) { if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever pointed to it.
continue; continue;
} }
@ -396,9 +372,6 @@ final class Graph
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
// 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 !== []) { if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) { foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) { if (isset($affectedSet[$testFile])) {
@ -509,9 +482,6 @@ final class Graph
$r = $baseline['results'][$testId]; $r = $baseline['results'][$testId];
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
// each variant via its specific factory. Keeps the stored message
// intact (important for skips/failures shown to the user).
return match ($r['status']) { return match ($r['status']) {
0 => TestStatus::success(), 0 => TestStatus::success(),
1 => TestStatus::skipped($r['message']), 1 => TestStatus::skipped($r['message']),
@ -585,7 +555,6 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree; $this->baselines[$branch]['tree'] = $tree;
} }
// Edges and tree snapshot stay intact; only the run-state is reset.
public function clearResults(string $branch): void public function clearResults(string $branch): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
@ -641,7 +610,6 @@ final class Graph
$this->link($testFile, $source); $this->link($testFile, $source);
} }
// Deduplicate ids for this test.
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel])); $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
} }
} }
@ -702,7 +670,6 @@ final class Graph
} }
} }
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
/** /**
* @param array<string, array<int, string>> $fileToComponents * @param array<string, array<int, string>> $fileToComponents
*/ */
@ -1086,7 +1053,6 @@ final class Graph
return TableExtractor::fromMigrationSource($content); return TableExtractor::fromMigrationSource($content);
} }
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
private function componentForInertiaPage(string $rel): ?string private function componentForInertiaPage(string $rel): ?string
{ {
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
@ -1275,8 +1241,6 @@ final class Graph
return $json === false ? null : $json; return $json === false ? null : $json;
} }
// 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 private function relative(string $path): ?string
{ {
if ($path === '' || $path === 'unknown') { if ($path === '' || $path === 'unknown') {
@ -1290,8 +1254,7 @@ final class Graph
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive || (strlen($path) >= 2 && $path[1] === ':');
if ($isAbsolute) { if ($isAbsolute) {
$real = @realpath($path); $real = @realpath($path);
@ -1303,7 +1266,6 @@ final class Graph
return null; return null;
} }
// Always forward slashes — git always uses them; Windows backslashes would never match.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else { } else {
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);

View File

@ -16,12 +16,6 @@ final class JsImportParser
private const string JS_DIR = 'resources/js'; private const string JS_DIR = 'resources/js';
/** /**
* Walks the project's pages directory (`resources/js/Pages` or its
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
* and, for each page, collects its transitive file imports. Returns
* the inverted graph so callers can look up "what pages depend on
* this shared file".
*
* @return array<string, list<string>> * @return array<string, list<string>>
*/ */
public static function parse(string $projectRoot): array public static function parse(string $projectRoot): array
@ -142,11 +136,6 @@ final class JsImportParser
} }
} }
/**
* Loads the importable region of a file. For Vue SFCs, only the
* `<script>` block is relevant for imports; ignoring the rest
* avoids false-positive matches inside `<template>` attributes.
*/
private static function loadSource(string $fileAbs): ?string private static function loadSource(string $fileAbs): ?string
{ {
$content = @file_get_contents($fileAbs); $content = @file_get_contents($fileAbs);
@ -169,10 +158,6 @@ final class JsImportParser
} }
/** /**
* Picks out every `import … from '…'` / `import '…'` / `import('…')`
* target. We strip line comments first so a commented-out import
* doesn't bloat the dep set.
*
* @return list<string> * @return list<string>
*/ */
private static function extractImports(string $source): array private static function extractImports(string $source): array
@ -209,9 +194,6 @@ final class JsImportParser
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail)); return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
} }
// Anything else is either a node_modules package or an
// unrecognised alias — skip. The watch-pattern fallback
// handles the safety-net case for non-matched paths.
return null; return null;
} }
@ -227,10 +209,6 @@ final class JsImportParser
return self::withExtension($path); return self::withExtension($path);
} }
/**
* Imports may omit the extension or point at a directory (index.vue,
* index.ts). Probe the common targets in order.
*/
private static function withExtension(string $path): ?string private static function withExtension(string $path): ?string
{ {
if (is_file($path)) { if (is_file($path)) {

View File

@ -16,30 +16,14 @@ final class JsModuleGraph
private const string CACHE_FILE = 'js-module-graph.cache.json'; 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; private static ?Process $warmer = null;
/** Fingerprint the warmer was started against — used to detect drift between warm and build. */
private static ?string $warmerFingerprint = null; private static ?string $warmerFingerprint = null;
/** True when the warmer found a fresh cache and skipped spawning Node. */
private static bool $warmerCacheHit = false; private static bool $warmerCacheHit = false;
/** Project root the warmer was launched for. */
private static ?string $warmerProjectRoot = null; 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 public static function warmInBackground(string $projectRoot): void
{ {
if (self::$warmer instanceof Process || self::$warmerCacheHit) { if (self::$warmer instanceof Process || self::$warmerCacheHit) {
@ -76,7 +60,7 @@ final class JsModuleGraph
} }
/** /**
* @return array<string, list<string>> project-relative source path → sorted list of page component names * @return array<string, list<string>>
*/ */
public static function build(string $projectRoot): array public static function build(string $projectRoot): array
{ {
@ -86,13 +70,6 @@ final class JsModuleGraph
} }
/** /**
* 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").
*
* @return array<string, list<string>>|null * @return array<string, list<string>>|null
*/ */
public static function buildStrict(string $projectRoot): ?array public static function buildStrict(string $projectRoot): ?array
@ -100,22 +77,12 @@ final class JsModuleGraph
return self::resolve($projectRoot); return self::resolve($projectRoot);
} }
/**
* True when the project looks like a Vite + Node project we can
* ask for a module graph. Gate for callers that want to skip the
* resolver entirely on non-Vite apps.
*/
public static function isApplicable(string $projectRoot): bool public static function isApplicable(string $projectRoot): bool
{ {
if (! self::hasViteConfig($projectRoot)) { if (! self::hasViteConfig($projectRoot)) {
return false; return false;
} }
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
// starter kit (`pages/`) conventions are accepted — projects
// running on a case-sensitive filesystem (Linux CI) get
// exactly one of the two, and we shouldn't refuse to walk the
// graph based on which one it picks.
foreach (['Pages', 'pages'] as $dir) { foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) { if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return true; return true;
@ -142,14 +109,7 @@ final class JsModuleGraph
} }
} }
// Pick up the warmer when it was launched against the same if (self::$warmerCacheHit && $fingerprint !== null) {
// 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); $cached = self::readCache($projectRoot, $fingerprint);
self::$warmerCacheHit = false; self::$warmerCacheHit = false;
self::$warmerFingerprint = null; self::$warmerFingerprint = null;
@ -160,60 +120,28 @@ final class JsModuleGraph
} }
} }
if (self::$warmer instanceof Process $process = self::$warmer;
&& self::$warmerFingerprint === $fingerprint self::$warmer = null;
&& self::$warmerProjectRoot === $projectRoot) { self::$warmerFingerprint = null;
$process = self::$warmer; self::$warmerProjectRoot = null;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
$process->wait();
if ($process instanceof Process && $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 instanceof Process) { if (! $process instanceof Process) {
return null; return null;
} }
$process->run(); $process->wait();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return null; return null;
} }
return self::parseNodeOutput($process->getOutput()); $result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
} }
private static function buildNodeProcess(string $projectRoot): ?Process private static function buildNodeProcess(string $projectRoot): ?Process
@ -238,11 +166,6 @@ final class JsModuleGraph
return null; return null;
} }
// Tell the Node helper which casing this project uses for its
// pages directory. The helper defaults to `resources/js/Pages`;
// the Laravel React starter ships lowercase `resources/js/pages`,
// and on a case-sensitive filesystem the helper would otherwise
// walk a non-existent directory and emit an empty module graph.
$env = []; $env = [];
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) { foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) { if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
@ -298,10 +221,6 @@ final class JsModuleGraph
return $out; 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 private static function reapWarmer(): void
{ {
$process = self::$warmer; $process = self::$warmer;

View File

@ -33,9 +33,6 @@ final class Recorder
/** @var array<string, bool> */ /** @var array<string, bool> */
private array $classUsesDatabaseCache = []; private array $classUsesDatabaseCache = [];
// 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>> */ /** @var array<string, list<string>> */
private array $fileToClassNames = []; private array $fileToClassNames = [];
@ -78,8 +75,6 @@ final class Recorder
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// 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'); $modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) { if (is_array($modes) && in_array('coverage', $modes, true)) {
@ -124,8 +119,6 @@ final class Recorder
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
} }
// 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->linkAncestorFiles($className);
$this->linkImportedFiles($file); $this->linkImportedFiles($file);
@ -136,7 +129,6 @@ final class Recorder
return; return;
} }
// Xdebug
\xdebug_start_code_coverage(); \xdebug_start_code_coverage();
} }
@ -149,15 +141,6 @@ final class Recorder
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\stop(); \pcov\stop();
// pcov\waiting() lists every file pcov has tracked but not
// yet collected for. Filter that list down to the project's
// source scope (phpunit.xml's `<source>` plus other
// top-level project dirs, minus vendor / caches), then ask
// pcov to collect *only* for those — `pcov\inclusive`
// narrows the result set at the driver level instead of us
// post-filtering after a full collect. Anything pcov saw
// outside the scope is dropped before any line counts come
// back.
$scope = $this->sourceScope(); $scope = $this->sourceScope();
$filesToCollectCoverageFor = []; $filesToCollectCoverageFor = [];
@ -174,7 +157,6 @@ final class Recorder
} else { } else {
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage(); $data = \xdebug_get_code_coverage();
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
\xdebug_stop_code_coverage(true); \xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data); $coveredFiles = array_keys($data);
@ -193,8 +175,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
// 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($coveredFiles); $this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null; $this->currentTestFile = null;
@ -334,7 +314,6 @@ final class Recorder
$files[$f] = true; $files[$f] = true;
}; };
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
foreach ($reflection->getInterfaceNames() as $iname) { foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname); $linkSymbol($iname);
} }
@ -639,8 +618,6 @@ final class Recorder
return null; return null;
} }
// 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 private function readPestFilename(string $className): ?string
{ {
if (! class_exists($className, false)) { if (! class_exists($className, false)) {
@ -667,17 +644,6 @@ final class Recorder
} }
/** /**
* Filters pcov's `file => line => executionCount` map to files that
* actually had executed code AND live inside the configured source
* scope (`phpunit.xml`'s `<source>` block, or the project root with
* vendor/etc. excluded as fallback).
*
* pcov reports `-1` for "executable but not run" and a positive
* count for executed lines. We also skip files where the *only*
* positive line is the implicit `ZEND_RETURN` at end-of-file: pcov
* surfaces that as a one-line artifact for files that were merely
* included (autoloaded) without any real code running.
*
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return list<string> * @return list<string>
*/ */
@ -700,9 +666,6 @@ final class Recorder
continue; continue;
} }
// Skip files where the only "executed" line is the implicit
// ZEND_RETURN at end-of-file (pcov artifact from being included
// but never actually run).
$lineKeys = array_keys($lines); $lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) { if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue; continue;

View File

@ -97,10 +97,6 @@ final class ResultCollector
} }
/** /**
* Injects externally-collected results (e.g. partials flushed by parallel
* 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, file?: string}> $results * @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/ */
public function merge(array $results): void public function merge(array $results): void
@ -118,11 +114,6 @@ final class ResultCollector
$this->startTime = null; $this->startTime = null;
} }
/**
* Called by the Finished subscriber after a test's outcome + assertion
* events have all fired. Clears the "currently recording" pointer so
* the next test's events don't get mis-attributed.
*/
public function finishTest(): void public function finishTest(): void
{ {
$this->currentTestId = null; $this->currentTestId = null;
@ -140,10 +131,6 @@ final class ResultCollector
? round(microtime(true) - $this->startTime, 3) ? round(microtime(true) - $this->startTime, 3)
: 0.0; : 0.0;
// PHPUnit can fire more than one outcome event per test — the
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
// Last-wins semantics preserve the most specific status; the
// existing assertion count (if any) survives the overwrite.
$existing = $this->results[$this->currentTestId] ?? null; $existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [ $this->results[$this->currentTestId] = [

View File

@ -9,11 +9,6 @@ namespace Pest\Plugins\Tia;
*/ */
final readonly class SourceScope final readonly class SourceScope
{ {
/**
* Top-level directory names always treated as out-of-scope. These
* mirror what a Laravel app considers "not source": dependencies,
* editor metadata, framework artefacts, the TIA state itself.
*/
private const array TOP_LEVEL_NOISE = [ private const array TOP_LEVEL_NOISE = [
'vendor', 'vendor',
'node_modules', 'node_modules',
@ -26,13 +21,6 @@ final readonly class SourceScope
'.cache', '.cache',
]; ];
/**
* Nested paths (relative to project root) that must be excluded
* even when their top-level parent is in scope. Laravel writes
* compiled views, route caches, and packaged manifests here on
* every framework boot — instrumenting them would burn cycles
* and create noisy edges.
*/
private const array NESTED_NOISE = [ private const array NESTED_NOISE = [
'storage/framework', 'storage/framework',
'storage/logs', 'storage/logs',
@ -80,12 +68,6 @@ final readonly class SourceScope
return new self($includes, $excludes); return new self($includes, $excludes);
} }
/**
* True when the absolute file path is inside an `<include>`
* directory and not under any exclude. Symlinks are resolved on
* the input so a `realpath()`'d coverage entry still matches a
* config that pointed at the unresolved tree.
*/
public function contains(string $absoluteFile): bool public function contains(string $absoluteFile): bool
{ {
$real = @realpath($absoluteFile); $real = @realpath($absoluteFile);
@ -108,10 +90,6 @@ final readonly class SourceScope
} }
/** /**
* Project-relative directories the resolver considers in scope.
* Useful for setting `pcov.directory` (a single common ancestor)
* or `\pcov\collect()`'s file filter.
*
* @return list<string> * @return list<string>
*/ */
public function includes(): array public function includes(): array
@ -159,11 +137,6 @@ final readonly class SourceScope
} }
/** /**
* Every top-level directory under `$projectRoot` except those on
* the noise list. Hidden entries (dotdirs) are skipped unless
* they're explicitly project source — keeping `.git/`, `.idea/`
* etc. out without an explicit allowlist.
*
* @return list<string> * @return list<string>
*/ */
private static function topLevelProjectDirs(string $projectRoot): array private static function topLevelProjectDirs(string $projectRoot): array
@ -228,8 +201,6 @@ final readonly class SourceScope
$real = @realpath($combined); $real = @realpath($combined);
if ($real === false) { if ($real === false) {
// Directory may not exist yet (e.g. generated source) — keep
// the unresolved path so a future file under it still matches.
return self::normalise($combined); return self::normalise($combined);
} }

View File

@ -9,9 +9,6 @@ namespace Pest\Plugins\Tia;
*/ */
final class Storage final class Storage
{ {
/**
* Directory where TIA's State blobs live for `$projectRoot`.
*/
public static function tempDir(string $projectRoot): string public static function tempDir(string $projectRoot): string
{ {
$home = self::homeDir(); $home = self::homeDir();
@ -28,15 +25,6 @@ final class Storage
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot); .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 public static function purge(string $projectRoot): void
{ {
$dir = self::tempDir($projectRoot); $dir = self::tempDir($projectRoot);
@ -77,11 +65,6 @@ final class Storage
@rmdir($dir); @rmdir($dir);
} }
/**
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
* Windows. Returns null if neither resolves to an existing
* directory, in which case callers fall back to project-local state.
*/
private static function homeDir(): ?string private static function homeDir(): ?string
{ {
foreach (['HOME', 'USERPROFILE'] as $key) { foreach (['HOME', 'USERPROFILE'] as $key) {
@ -96,27 +79,7 @@ final class Storage
} }
/** /**
* Folder name for `$projectRoot` under `~/.pest/tia/`.
*
* Strategy — each step rules out a class of collision:
*
* 1. If the project has a git origin URL, use a **normalised** form
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar` * `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
* and `https://github.com/foo/bar` all collapse to
* `github.com/foo/bar` — three developers cloning the same repo
* by different transports share one cache, which is what we want.
* 2. Otherwise, use the canonicalised absolute path (`realpath`).
* Two unrelated `app/` checkouts under different parent folders
* have different realpaths → different hashes → isolated.
* 3. Hash the chosen input with sha256 and keep the first 16 hex
* chars — 64 bits of entropy makes accidental collision
* astronomically unlikely even across thousands of projects.
* 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/`
* is readable; the slug is cosmetic only, all isolation comes
* from the hash.
*
* Result: `myapp-a1b2c3d4e5f67890`.
*/ */
private static function projectKey(string $projectRoot): string private static function projectKey(string $projectRoot): string
{ {
@ -131,12 +94,6 @@ final class Storage
return $slug === '' ? $hash : $slug.'-'.$hash; return $slug === '' ? $hash : $slug.'-'.$hash;
} }
/**
* Canonical git origin identity for `$projectRoot`, or null when
* no origin URL can be parsed. The returned form is
* `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git
* protocol clones of the same remote produce the same value.
*/
private static function originIdentity(string $projectRoot): ?string private static function originIdentity(string $projectRoot): ?string
{ {
$url = self::rawOriginUrl($projectRoot); $url = self::rawOriginUrl($projectRoot);
@ -155,8 +112,6 @@ final class Storage
return strtolower($m[1].'/'.$m[2]); return strtolower($m[1].'/'.$m[2]);
} }
// Unrecognised form — hash the raw URL so different inputs still
// diverge, but lowercased so the only variance is intentional.
return strtolower($url); return strtolower($url);
} }
@ -181,11 +136,6 @@ final class Storage
return null; return null;
} }
/**
* Filesystem-safe kebab of `$name`. Cosmetic only — used as a
* human-readable prefix on the hash so `~/.pest/tia/` lists
* recognisable folders.
*/
private static function slug(string $name): string private static function slug(string $name): string
{ {
$slug = strtolower($name); $slug = strtolower($name);

View File

@ -9,18 +9,10 @@ namespace Pest\Plugins\Tia;
*/ */
final class TableExtractor final class TableExtractor
{ {
/**
* DML prefixes we accept. DDL (`CREATE`, `ALTER`, `DROP`,
* `TRUNCATE`, `RENAME`) is deliberately excluded — those come
* from migrations fired by `RefreshDatabase`, and capturing them
* here would attribute every migration table to every test.
*/
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete']; private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
/** /**
* @return list<string> Sorted, deduped table names referenced by the * @return list<string> Sorted, deduped table names referenced by the
* SQL statement. Empty when the statement is
* DDL, empty, or unparseable.
*/ */
public static function fromSql(string $sql): array public static function fromSql(string $sql): array
{ {
@ -45,9 +37,6 @@ final class TableExtractor
return []; return [];
} }
// Match `from`, `into`, `update`, `join` and capture the
// following identifier, tolerating the common quoting
// styles: "double", `back`, [bracket], or bare.
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i'; $pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
if (preg_match_all($pattern, $sql, $matches) === false) { if (preg_match_all($pattern, $sql, $matches) === false) {
@ -82,35 +71,11 @@ final class TableExtractor
/** /**
* @return list<string> Table names referenced by `Schema::` calls, * @return list<string> Table names referenced by `Schema::` calls,
* raw DDL, or DML inside the given migration
* file contents. Empty when nothing matches —
* callers treat that as "fall back to the
* broad watch pattern".
*
* Three passes:
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
* captures the conventional Laravel migration shape.
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
* TRUNCATE / RENAME TABLE <name>` patterns inside string
* literals (i.e. `DB::statement('CREATE TABLE …')`,
* `DB::unprepared('ALTER TABLE …')`).
* 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
* `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
* Catches the seeded-lookup-table case where a migration
* populates rows that tests later read.
*
* False positives possible when the same syntax appears in a
* comment or unrelated string, but over-attribution is
* correctness-safe.
*/ */
public static function fromMigrationSource(string $php): array public static function fromMigrationSource(string $php): array
{ {
$tables = []; $tables = [];
// Pass 1: Schema:: calls. `dropColumn` (singular) covers
// `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
// — the closure body's column op is on Blueprint, but the
// outer `Schema::table('users', …)` is what we capture here.
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/'; $schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) { if (preg_match_all($schemaPattern, $php, $matches) !== false) {
@ -124,10 +89,6 @@ final class TableExtractor
} }
} }
// Pass 2: raw DDL fallback. Matches the table name following
// `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres'
// `IF EXISTS` / `IF NOT EXISTS` variants), with optional
// ANSI / MySQL / SQL Server quoting.
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i'; $ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
if (preg_match_all($ddlPattern, $php, $matches) !== false) { if (preg_match_all($ddlPattern, $php, $matches) !== false) {
@ -139,14 +100,6 @@ final class TableExtractor
} }
} }
// Pass 3: DML inside migration bodies. Migrations that seed
// lookup tables via `DB::statement('INSERT INTO roles …')`,
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
// `DELETE FROM bar` are common in Laravel. Without picking
// these up, an edit to the seed payload would route through
// only the schema'd tables and silently skip every test that
// reads from the populated table. Fluent-builder calls
// (`DB::table('x')`) and raw SQL strings are both covered.
$dmlPatterns = [ $dmlPatterns = [
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i', '/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i', '/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
@ -172,11 +125,6 @@ final class TableExtractor
return $out; return $out;
} }
/**
* Filters out driver-internal tables that show up as DB::listen
* targets without representing user schema: SQLite's master
* catalogue, Laravel's own `migrations` metadata.
*/
private static function isSchemaMeta(string $name): bool private static function isSchemaMeta(string $name): bool
{ {
$lower = strtolower($name); $lower = strtolower($name);

View File

@ -11,13 +11,6 @@ final class TableTracker
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* App-scoped marker that makes `arm()` idempotent across the 774
* per-test `setUp()` calls — Laravel reuses the same app instance
* within a single test run, so without this guard we'd stack
* one listener per test and each query would fire the closure
* hundreds of times.
*/
private const string MARKER = 'pest.tia.table-tracker-armed'; private const string MARKER = 'pest.tia.table-tracker-armed';
public static function arm(Recorder $recorder): void public static function arm(Recorder $recorder): void
@ -66,12 +59,6 @@ final class TableTracker
} }
}; };
// Preferred path: `DatabaseManager::listen(Closure $callback)`.
// It's a real method — `method_exists` returns false because
// some Laravel versions compose it via a trait the reflection
// probe can't always see, so we gate via `is_callable` instead.
// This path pushes the listener onto every existing AND future
// connection, which is what we want for a process-wide capture.
/** @var object $db */ /** @var object $db */
$db = $app->make('db'); $db = $app->make('db');
@ -83,11 +70,6 @@ final class TableTracker
return; return;
} }
// Fallback: register directly on the event dispatcher. Works
// as long as every connection shares the same dispatcher
// instance this app resolved to — true in vanilla setups,
// but not guaranteed with connections instantiated pre-arm
// that captured an older dispatcher.
if (! $app->bound('events')) { if (! $app->bound('events')) {
return; return;
} }
@ -99,11 +81,6 @@ final class TableTracker
return; return;
} }
// Event class key intentionally has no leading backslash —
// `Dispatcher::listen()` stores by the literal string and the
// lookup at dispatch time uses `get_class($event)` (no
// leading backslash), so a `\Illuminate\…` key would never
// match the fired event.
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener); $events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
} }
} }

View File

@ -16,9 +16,6 @@ final readonly class Browser implements WatchDefault
{ {
public function applicable(): bool public function applicable(): bool
{ {
// Browser tests can exist in any PHP project. We only activate when
// there is an actual `tests/Browser` directory OR pest-plugin-browser
// is installed.
return class_exists(InstalledVersions::class) return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser'); && InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
} }
@ -37,12 +34,8 @@ final readonly class Browser implements WatchDefault
'resources/css/**/*.css', 'resources/css/**/*.css',
'resources/css/**/*.scss', 'resources/css/**/*.scss',
'resources/css/**/*.less', 'resources/css/**/*.less',
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js', 'public/build/**/*.js',
'public/build/**/*.css', '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/**/*.js',
'public/**/*.css', 'public/**/*.css',
'public/**/*.svg', 'public/**/*.svg',
@ -79,9 +72,6 @@ final readonly class Browser implements WatchDefault
$targets[] = $candidate; $targets[] = $candidate;
} }
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find exact tests using `visit()` outside the
// conventional Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) { if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests; $repo = TestSuite::getInstance()->tests;

View File

@ -22,19 +22,6 @@ final readonly class Inertia implements WatchDefault
{ {
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath); $browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
// Inertia page components (React / Vue / Svelte). Scoped to
// browser tests only — a Vue/React edit cannot change the
// output of a server-side Inertia test (those assert on the
// component *name* returned by `Inertia::render()`, not its
// client-side implementation). Broad invalidation is only
// meaningful for tests that actually render the DOM. Precise
// per-component edges come from `InertiaEdges` at record
// time and replace this fallback when available.
//
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
// React starter kit, and other lowercase-by-default setups)
// are emitted — paths from git are case-sensitive on Linux,
// so a single casing would silently miss the other convention.
$patterns = []; $patterns = [];
foreach (['Pages', 'pages'] as $pages) { foreach (['Pages', 'pages'] as $pages) {
@ -49,7 +36,6 @@ final readonly class Inertia implements WatchDefault
} }
} }
// SSR entry point.
$patterns['resources/js/ssr.js'] = $browserTargets; $patterns['resources/js/ssr.js'] = $browserTargets;
$patterns['resources/js/ssr.ts'] = $browserTargets; $patterns['resources/js/ssr.ts'] = $browserTargets;
$patterns['resources/js/app.js'] = $browserTargets; $patterns['resources/js/app.js'] = $browserTargets;

View File

@ -20,42 +20,23 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
// Config — loaded during app boot (setUp), invisible to coverage.
// Affects both Feature and Unit: Pest.php commonly binds fakes
// and seeds DB based on config values.
'config/*.php' => [$testPath], 'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath], 'config/**/*.php' => [$testPath],
// Routes — loaded during boot. HTTP/Feature tests depend on them.
'routes/*.php' => [$testPath], 'routes/*.php' => [$testPath],
'routes/**/*.php' => [$testPath], 'routes/**/*.php' => [$testPath],
// Service providers / bootstrap — loaded during boot, affect
// bindings, middleware, event listeners, scheduled tasks.
'bootstrap/app.php' => [$testPath], 'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath], 'bootstrap/providers.php' => [$testPath],
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
// setUp. Schema changes can break any test that touches DB.
'database/migrations/**/*.php' => [$testPath], 'database/migrations/**/*.php' => [$testPath],
// Seeders — often run globally via Pest.php beforeEach.
'database/seeders/**/*.php' => [$testPath], 'database/seeders/**/*.php' => [$testPath],
// Factories — loaded lazily but still PHP that coverage may miss
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath], '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], '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/**/*.tpl' => [$testPath],
'app/**/*.stub' => [$testPath], 'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath], 'app/**/*.json' => [$testPath],
@ -63,25 +44,16 @@ final readonly class Laravel implements WatchDefault
'app/**/*.yml' => [$testPath], 'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath], 'app/**/*.txt' => [$testPath],
// Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$testPath], '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], '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], 'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$testPath], 'resources/views/emails/**/*.blade.php' => [$testPath],
// Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot).
'lang/**/*.php' => [$testPath], 'lang/**/*.php' => [$testPath],
'lang/**/*.json' => [$testPath], 'lang/**/*.json' => [$testPath],
'resources/lang/**/*.php' => [$testPath], 'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$testPath], 'resources/lang/**/*.json' => [$testPath],
// Build tool config — affects compiled assets consumed by
// browser and Inertia tests.
'vite.config.js' => [$testPath], 'vite.config.js' => [$testPath],
'vite.config.ts' => [$testPath], 'vite.config.ts' => [$testPath],
'webpack.mix.js' => [$testPath], 'webpack.mix.js' => [$testPath],

View File

@ -20,15 +20,10 @@ final readonly class Livewire implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
// Livewire views live alongside Blade views or in a dedicated dir.
'resources/views/livewire/**/*.blade.php' => [$testPath], 'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath], 'resources/views/components/**/*.blade.php' => [$testPath],
// Volt's second default mount — single-file components used as
// full-page routes. Missing this means editing a Volt page
// doesn't re-run its tests.
'resources/views/pages/**/*.blade.php' => [$testPath], 'resources/views/pages/**/*.blade.php' => [$testPath],
// Livewire JS interop / Alpine plugins.
'resources/js/**/*.js' => [$testPath], 'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath], 'resources/js/**/*.ts' => [$testPath],
]; ];

View File

@ -16,55 +16,24 @@ final readonly class Php implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
// NOTE: composer.json / composer.lock changes are caught by the
// fingerprint (which hashes composer.lock). PHP files are tracked by
// the coverage driver. Only non-PHP, non-fingerprinted files that
// can silently alter test behaviour belong here.
return [ return [
// Environment files — can change DB drivers, feature flags,
// queue connections, etc. Not PHP, not fingerprinted. Covers
// the local-override variants (`.env.local`, `.env.testing.local`)
// that both Laravel and Symfony recommend for machine-specific
// config.
'.env' => [$testPath], '.env' => [$testPath],
'.env.testing' => [$testPath], '.env.testing' => [$testPath],
'.env.local' => [$testPath], '.env.local' => [$testPath],
'.env.*.local' => [$testPath], '.env.*.local' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath], 'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath], 'docker-compose.yaml' => [$testPath],
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
// phpunit.xml.dist and other XML overrides are not individually
// tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath], 'phpunit.xml.dist' => [$testPath],
// `tests/Pest.php` is loaded once per suite (during BootFiles)
// so its `pest()->extend()`, `expect()->extend()`, helpers,
// etc. execute outside the per-test coverage window — no
// edge captures it. Watch-pattern broadcast triggers a
// replay of every test (results refresh) without a full
// record-mode graph rebuild.
$testPath.'/Pest.php' => [$testPath], $testPath.'/Pest.php' => [$testPath],
// Pest dataset definitions are loaded once at boot, outside
// the per-test coverage window — no edge captures them. A
// change to a shared dataset can flip the result of any test
// that uses it, so broadcast every dataset edit to the full
// suite.
$testPath.'/Datasets/**/*.php' => [$testPath], $testPath.'/Datasets/**/*.php' => [$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],
$testPath.'/**/Fixtures/**/*' => [$testPath], $testPath.'/**/Fixtures/**/*' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath], $testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
]; ];
} }

View File

@ -19,12 +19,7 @@ final readonly class Symfony implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
// Symfony boots the kernel in setUp() (before the coverage window).
// PHP config, routes, kernel, and migrations are loaded during boot
// and invisible to the coverage driver. Same reasoning as Laravel.
return [ return [
// Config — YAML, XML, and PHP. All loaded during kernel boot.
'config/*.yaml' => [$testPath], 'config/*.yaml' => [$testPath],
'config/*.yml' => [$testPath], 'config/*.yml' => [$testPath],
'config/*.php' => [$testPath], 'config/*.php' => [$testPath],
@ -34,37 +29,27 @@ final readonly class Symfony implements WatchDefault
'config/**/*.php' => [$testPath], 'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$testPath], 'config/**/*.xml' => [$testPath],
// Routes — loaded during boot.
'config/routes/*.yaml' => [$testPath], 'config/routes/*.yaml' => [$testPath],
'config/routes/*.php' => [$testPath], 'config/routes/*.php' => [$testPath],
'config/routes/*.xml' => [$testPath], 'config/routes/*.xml' => [$testPath],
'config/routes/**/*.yaml' => [$testPath], 'config/routes/**/*.yaml' => [$testPath],
// Kernel / bootstrap — loaded during boot.
'src/Kernel.php' => [$testPath], 'src/Kernel.php' => [$testPath],
// Migrations — run during setUp (before coverage window).
// DoctrineMigrationsBundle's default is `migrations/` at the
// project root; many Symfony projects relocate to
// `src/Migrations/` — both covered.
'migrations/**/*.php' => [$testPath], 'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath], 'src/Migrations/**/*.php' => [$testPath],
// Twig templates — compiled, source not PHP-executed.
'templates/**/*.html.twig' => [$testPath], 'templates/**/*.html.twig' => [$testPath],
'templates/**/*.twig' => [$testPath], 'templates/**/*.twig' => [$testPath],
// Translations (YAML / XLF / XLIFF).
'translations/**/*.yaml' => [$testPath], 'translations/**/*.yaml' => [$testPath],
'translations/**/*.yml' => [$testPath], 'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath], 'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath], 'translations/**/*.xliff' => [$testPath],
// Doctrine XML/YAML mappings.
'config/doctrine/**/*.xml' => [$testPath], 'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath], 'config/doctrine/**/*.yaml' => [$testPath],
// Webpack Encore / asset-mapper config + frontend sources.
'webpack.config.js' => [$testPath], 'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath], 'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath], 'assets/**/*.js' => [$testPath],

View File

@ -9,9 +9,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
*/ */
interface WatchDefault interface WatchDefault
{ {
/**
* Whether this default set applies to the current project.
*/
public function applicable(): bool; public function applicable(): bool;
/** /**

View File

@ -13,8 +13,6 @@ use Pest\TestSuite;
final class WatchPatterns final class WatchPatterns
{ {
/** /**
* All known default providers, in evaluation order.
*
* @var array<int, class-string<WatchDefault>> * @var array<int, class-string<WatchDefault>>
*/ */
private const array DEFAULTS = [ private const array DEFAULTS = [
@ -37,12 +35,6 @@ final class WatchPatterns
private bool $filtered = 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
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
* calls are already in `$this->patterns`).
*/
public function useDefaults(string $projectRoot): void public function useDefaults(string $projectRoot): void
{ {
$testPath = TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
@ -63,9 +55,6 @@ final class WatchPatterns
} }
/** /**
* Adds user-defined patterns. Merges with existing entries so a single
* glob can map to multiple directories.
*
* @param array<string, string> $patterns glob → project-relative test dir/file * @param array<string, string> $patterns glob → project-relative test dir/file
*/ */
public function add(array $patterns): void public function add(array $patterns): void
@ -78,9 +67,6 @@ final class WatchPatterns
} }
/** /**
* Returns all test targets whose watch patterns match at least one of
* the given changed files.
*
* @param string $projectRoot Absolute path. * @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths. * @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files. * @return array<int, string> Project-relative test dirs/files.
@ -107,9 +93,6 @@ final class WatchPatterns
} }
/** /**
* Given the affected targets, returns every test file in the graph that
* either matches an exact file target or lives under a directory target.
*
* @param array<int, string> $directories Project-relative dirs/files. * @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph. * @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string> * @return array<int, string>
@ -181,11 +164,6 @@ final class WatchPatterns
$this->filtered = false; $this->filtered = false;
} }
/**
* Matches a project-relative file against a glob pattern.
*
* Supports `*` (single segment), `**` (any depth) and `?`.
*/
private function globMatches(string $pattern, string $file): bool private function globMatches(string $pattern, string $file): bool
{ {
$pattern = str_replace('\\', '/', $pattern); $pattern = str_replace('\\', '/', $pattern);

View File

@ -40,11 +40,6 @@ final class XdebugRestarter implements Restarter
(new XdebugHandler('pest'))->check(); (new XdebugHandler('pest'))->check();
} }
/**
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
* for older Xdebug without `xdebug_info` — be conservative and leave it
* loaded; we can't prove the mode is safe to drop.
*/
private function xdebugIsCoverageOnly(): bool private function xdebugIsCoverageOnly(): bool
{ {
if (! function_exists('xdebug_info')) { if (! function_exists('xdebug_info')) {
@ -67,11 +62,6 @@ final class XdebugRestarter implements Restarter
} }
/** /**
* TIA must be enabled for this run, no coverage flag, no forced
* rebuild, and TIA must be about to replay rather than record. Plain
* `pest` (and anything else without TIA enabled) keeps Xdebug loaded
* so non-TIA users aren't surprised by behaviour changes.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
*/ */
private function runLooksDroppable(array $arguments, string $projectRoot): bool private function runLooksDroppable(array $arguments, string $projectRoot): bool
@ -95,12 +85,6 @@ final class XdebugRestarter implements Restarter
return $this->tiaWillReplay($projectRoot); return $this->tiaWillReplay($projectRoot);
} }
/**
* True when a valid TIA graph already lives on disk AND its structural
* fingerprint matches the current environment. Any other outcome
* (missing graph, unreadable JSON, structural drift) means TIA will
* record and the driver must stay loaded.
*/
private function tiaWillReplay(string $projectRoot): bool private function tiaWillReplay(string $projectRoot): bool
{ {
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; $path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;

View File

@ -27,10 +27,6 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
); );
} }
// Close the "currently recording" window on Finished so the next
// test's events don't get mis-attributed. Keeping the pointer open
// through the outcome subscribers is what lets a late-firing
// `ConsideredRisky` overwrite an earlier `Passed`.
$this->collector->finishTest(); $this->collector->finishTest();
} }
} }