diff --git a/pint.json b/pint.json new file mode 100644 index 00000000..cd7b3c6a --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "rules": { + "Pint/phpdoc_type_annotations_only": true + } +} diff --git a/src/Contracts/Restarter.php b/src/Contracts/Restarter.php index 34a91fdc..95324301 100644 --- a/src/Contracts/Restarter.php +++ b/src/Contracts/Restarter.php @@ -10,8 +10,6 @@ namespace Pest\Contracts; interface Restarter { /** - * Re-execs the PHP process when conditions warrant it. - * * @param array $arguments */ public function maybeRestart(string $projectRoot, array $arguments): void; diff --git a/src/Exceptions/NoAffectedTestsFound.php b/src/Exceptions/NoAffectedTestsFound.php index 29b144bd..162dacc2 100644 --- a/src/Exceptions/NoAffectedTestsFound.php +++ b/src/Exceptions/NoAffectedTestsFound.php @@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface; */ final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace { - /** - * Renders the panic on the given output. - */ public function render(OutputInterface $output): void { $output->writeln([ @@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc ]); } - /** - * The exit code to be used. - */ public function exitCode(): int { return 0; diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 1fbed909..b952ffff 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -54,7 +54,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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-'; 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'; - /** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */ 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 bool $graphWritten = false; @@ -109,10 +106,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private bool $forceRefetch = false; - /** Prevents fetching the same stale baseline twice after structural drift. */ private bool $baselineFetchAttemptedForDrift = false; - /** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */ private bool $freshRebuild = 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 $arguments */ public static function isEnabledForRun(array $arguments): bool @@ -183,10 +169,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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)); } @@ -258,8 +240,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $freshRequested = $this->hasArgument(self::FRESH_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::FRESH_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->setFingerprint(Fingerprint::compute($projectRoot)); $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( $this->branch, $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, environmental: array} $current */ private function reconcileFingerprint(Graph $graph, array $current): ?Graph @@ -594,14 +568,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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) { 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 && ! $forceRebuild && ! $this->baselineFetchAttemptedForDrift @@ -643,17 +605,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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)) { 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)) { 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 $arguments * @return array */ @@ -820,12 +769,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $failedFromCache = []; 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); } @@ -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 $changedFiles * @param array $affectedFromChanges * @param array $failedFromCache @@ -908,9 +840,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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 === [] ? 0 : count(array_diff($failedFromCache, $affectedFromChanges)); @@ -954,9 +883,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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; $sorted = $affected; sort($sorted); @@ -1273,8 +1199,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private function registerRecap(): void { 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()) { $this->mergeWorkerReplayPartials(); } @@ -1364,13 +1288,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable foreach ($results as $testId => $result) { $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")) { $file = $this->resolveFailedTestFile($testId); } @@ -1390,22 +1307,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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 { $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 $changedFiles */ private function hasProjectPhpSourceChanges(array $changedFiles): bool diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 6435df91..8afa4e06 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -26,20 +26,10 @@ final readonly class BaselineSync private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; - // Subdirectory under the per-project state dir (`~/.pest/tia//`) - // 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'; - // Most recently downloaded artifacts to retain on disk. Branch - // switches and partial baseline rollouts hop across run ids — keeping - // the last few avoids re-downloading when the user toggles between - // them. Older entries get evicted on the next download. private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5; - // 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`. private const int FETCH_COOLDOWN_SECONDS = 86400; public function __construct( @@ -78,11 +68,6 @@ final readonly class BaselineSync $payload = $this->download($repo, $projectRoot, $failureKind); 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) { $this->startCooldown(); $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('.github/workflows/tia-baseline.yml'); - // YAML stays as a raw indented block — Termwind would mangle the - // verbatim whitespace. $indentedYaml = array_map( static fn (string $line): string => ' '.$line, explode("\n", $yaml), @@ -182,7 +165,6 @@ final readonly class BaselineSync $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 { return getenv('GITHUB_ACTIONS') === 'true' @@ -326,9 +308,6 @@ YAML; if ($listError !== null) { $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)) { Panic::with(new BaselineFetchFailed( 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( 'Failed to query baseline runs — %s', $listError['message'], @@ -347,7 +324,6 @@ YAML; } if ($runId === null) { - // Genuine missing baseline — caller emits publish instructions. $failureKind = 'no-runs'; return null; @@ -355,12 +331,7 @@ YAML; $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)) { - // Bump the dir mtime so trimDownloadCache() treats this run - // id as recently used and doesn't evict it later. @touch($runCacheDir); $this->renderBadge('INFO', sprintf( @@ -414,7 +385,6 @@ YAML; $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); $failureKind = $diagnosis['kind']; - // Tier 1 — actionable. Stop hard with a clear diagnostic. if (in_array($failureKind, ['forbidden', 'not-found'], true)) { Panic::with(new BaselineFetchFailed( 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( 'Baseline download failed — %s', $diagnosis['message'], @@ -436,9 +405,6 @@ YAML; if ($payload === null) { $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( '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.', @@ -450,11 +416,6 @@ YAML; 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 { $process = new Process([ @@ -484,11 +445,6 @@ YAML; $speed = (int) ($current / $elapsed); 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)); $message = sprintf( ' 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); } @@ -565,11 +519,6 @@ YAML; 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 { $sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? ''; @@ -577,13 +526,6 @@ YAML; return $sanitised === '' ? 'unknown' : $sanitised; } - /** - * Keep the N most recently used cached artifacts and evict the rest. - * Recency is taken from the directory mtime — `mkdir`/`gh run download` - * stamps it on a fresh entry, and a cache hit `touch`es it back to - * the front of the line, so a frequently-reused run id won't be - * evicted just because newer ids have been seen between uses. - */ private function trimDownloadCache(string $projectRoot): void { $root = $this->downloadCacheDir($projectRoot); @@ -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}} */ 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} */ 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")); return ['kind' => 'unknown', 'message' => $message]; diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php index 5825e5dd..2c83eb13 100644 --- a/src/Plugins/Tia/Bootstrapper.php +++ b/src/Plugins/Tia/Bootstrapper.php @@ -22,11 +22,7 @@ final readonly class Bootstrapper implements BootstrapperContract } /** - * TIA's per-project state directory. Default layout is - * `~/.pest/tia//` 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 - * derivation and the home-dir-missing fallback. */ private function tempDir(): string { diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 68b15959..4f4ac93c 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -24,7 +24,6 @@ final readonly class ChangedFiles 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); foreach (array_keys($lastRunTree) as $snapshotted) { @@ -45,8 +44,6 @@ final readonly class ChangedFiles } 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; 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 $files * @return array path → xxh128 content hash */ @@ -86,9 +79,6 @@ final readonly class ChangedFiles $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; 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] = ''; continue; @@ -106,8 +96,6 @@ final readonly class ChangedFiles /** * @return array|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 { @@ -127,9 +115,6 @@ final readonly class ChangedFiles $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 = []; foreach ($files as $file) { @@ -144,13 +129,6 @@ final readonly class ChangedFiles $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 !== '') { return $this->filterBehaviourallyUnchanged($candidates, $sha); } @@ -170,7 +148,6 @@ final readonly class ChangedFiles $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; if (! is_file($absolute)) { - // Deleted on disk — a genuine change, keep it. $remaining[] = $file; continue; @@ -187,8 +164,6 @@ final readonly class ChangedFiles $baselineContent = $this->contentAtSha($sha, $file); if ($baselineContent === null) { - // Couldn't read the baseline (new file, binary, `git show` - // failed). Err on the side of re-running. $remaining[] = $file; continue; @@ -204,12 +179,6 @@ final readonly class ChangedFiles 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 { $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); @@ -231,10 +200,6 @@ final readonly class ChangedFiles '.phpunit.result.cache', 'vendor/', '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/', ]; @@ -281,9 +246,6 @@ final readonly class ChangedFiles ); $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; } @@ -310,14 +272,6 @@ final readonly class ChangedFiles */ 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 ` for most entries, and - // `R ` for renames/copies (two NUL-separated - // fields). $process = new Process( ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], $this->projectRoot, @@ -348,8 +302,6 @@ final readonly class ChangedFiles $status = substr($record, 0, 2); $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') { $files[] = $path; diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php index d7e5165b..014a7d74 100644 --- a/src/Plugins/Tia/Configuration.php +++ b/src/Plugins/Tia/Configuration.php @@ -12,8 +12,6 @@ use Pest\Support\Container; final class Configuration { /** - * Activates TIA for every run without requiring the `--tia` CLI flag. - * * @return $this */ 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 */ 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 */ 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 $patterns glob → project-relative test dir * @return $this */ diff --git a/src/Plugins/Tia/ContentHash.php b/src/Plugins/Tia/ContentHash.php index 79b718b6..f8538bb5 100644 --- a/src/Plugins/Tia/ContentHash.php +++ b/src/Plugins/Tia/ContentHash.php @@ -9,11 +9,6 @@ namespace Pest\Plugins\Tia; */ 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 { $raw = @file_get_contents($absolute); @@ -25,11 +20,6 @@ final class ContentHash 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 :`) and - * want to avoid a disk round-trip. - */ public static function ofContent(string $path, string $raw): string { $lower = strtolower($path); @@ -51,13 +41,6 @@ final class ContentHash 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 { $tokens = @token_get_all($raw); @@ -88,14 +71,6 @@ final class ContentHash 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 { $stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw; @@ -104,17 +79,6 @@ final class ContentHash 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 { $stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw; diff --git a/src/Plugins/Tia/Contracts/State.php b/src/Plugins/Tia/Contracts/State.php index 6a0ba155..79437b2f 100644 --- a/src/Plugins/Tia/Contracts/State.php +++ b/src/Plugins/Tia/Contracts/State.php @@ -9,33 +9,15 @@ namespace Pest\Plugins\Tia\Contracts; */ 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; - /** - * 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; - /** - * 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 exists(string $key): bool; /** - * Returns every key whose name starts with `$prefix`. Used to collect - * paratest worker partials (`worker-edges-.json`, etc.) without - * exposing backend-specific glob semantics. - * * @return list */ public function keysWithPrefix(string $prefix): array; diff --git a/src/Plugins/Tia/CoverageCollector.php b/src/Plugins/Tia/CoverageCollector.php index 4fff521e..1c4f5213 100644 --- a/src/Plugins/Tia/CoverageCollector.php +++ b/src/Plugins/Tia/CoverageCollector.php @@ -14,19 +14,11 @@ use Throwable; 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 */ private array $classFileCache = []; /** - * Rebuilds the same `absolute test file → list` - * shape that `Recorder::perTestFiles()` exposes, so callers can treat - * the two collectors interchangeably when feeding the graph. - * * @return array> */ public function perTestFiles(): array @@ -48,9 +40,6 @@ final class CoverageCollector $edges = []; 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 = []; foreach ($lines as $hits) { @@ -90,9 +79,6 @@ final class CoverageCollector 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, '#'); $identifier = $hash === false ? $testId : substr($testId, 0, $hash); @@ -120,9 +106,6 @@ final class CoverageCollector $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')) { $property = $reflection->getProperty('__filename'); diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php index 6efc5a17..c8050f1f 100644 --- a/src/Plugins/Tia/CoverageMerger.php +++ b/src/Plugins/Tia/CoverageMerger.php @@ -28,9 +28,6 @@ final class CoverageMerger $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); 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); if ($current instanceof CodeCoverage) { @@ -61,8 +58,6 @@ final class CoverageMerger $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( $reportPath, '`) shapes. Returns null for any - * non-Inertia response so the caller can ignore it cheaply. - */ 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)) { $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 — ``. 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 — `
…`. 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); if ($content === null) { return null; } - // Lookahead pair handles arbitrary attribute order on the - // `#s', $content, $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=') && preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) { $component = self::componentFromJson(html_entity_decode($match[1])); @@ -167,12 +122,6 @@ final class InertiaEdges 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 { /** @var mixed $decoded */ diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php index 84ee62f4..92dd4c03 100644 --- a/src/Plugins/Tia/FileState.php +++ b/src/Plugins/Tia/FileState.php @@ -11,11 +11,6 @@ use Pest\Plugins\Tia\Contracts\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; public function __construct(string $rootDir) @@ -49,8 +44,6 @@ final readonly class FileState implements State 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)) { @unlink($tmp); @@ -100,22 +93,11 @@ final readonly class FileState implements State 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 { 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 { $resolved = @realpath($this->rootDir); @@ -123,10 +105,6 @@ final readonly class FileState implements State 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 { if (is_dir($this->rootDir)) { diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 36e5d6ae..1d78d85c 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -22,7 +22,6 @@ final readonly class Fingerprint return [ 'structural' => [ 'schema' => self::SCHEMA_VERSION, - // 'composer_lock' => self::composerLockHash($projectRoot), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), @@ -34,10 +33,6 @@ final readonly class Fingerprint 'composer_json' => self::composerJsonHash($projectRoot), ], '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'); } - // Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild. /** * @param array $fingerprint * @return array diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index f232ddcf..796ef4a6 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -46,7 +46,6 @@ final class Graph */ private array $baselines = []; - // Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink. private readonly string $projectRoot; /** @var array|null */ @@ -95,9 +94,6 @@ final class Graph $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 = []; $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 = []; 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 = []; foreach ($nonMigrationPaths as $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 = []; foreach ($nonMigrationPaths as $rel) { if (isset($globalFrontendRuntimeFiles[$rel])) { @@ -229,8 +218,6 @@ final class Graph $freshMap = JsModuleGraph::buildStrict($this->projectRoot); 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', [ 'type' => 'WARN', 'content' => sprintf( @@ -243,7 +230,6 @@ final class Graph $pages = $freshMap[$rel] ?? []; if ($pages === []) { - // Vite confirms no page imports this file — suppress the watch broadcast. $sharedFilesResolved[$rel] = true; 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 = []; $unknownSourceDirs = []; $sourcePhpChanged = false; @@ -301,7 +285,6 @@ final class Graph $absolute = $this->projectRoot.'/'.$rel; if (! is_file($absolute)) { - // Deleted source file unknown to the graph — no edge ever pointed to it. 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) { foreach (array_keys($this->edges) as $testFile) { if ($this->isArchTestFile($testFile)) { @@ -336,7 +317,6 @@ final class Graph } // Unknown Blade files: walk static references (@include, @extends, ) up to rendered - // ancestors and invalidate only tests that covered them. $staticallyHandledBlade = []; foreach ($nonMigrationPaths as $rel) { if (isset($this->fileIds[$rel])) { @@ -358,13 +338,10 @@ final class Graph $staticallyHandledBlade[$rel] = true; } elseif ($this->isBladeComponentPath($rel)) { - // Anonymous component with no static usages — treat as orphan rather than broadcasting. $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; foreach ($nonMigrationPaths as $rel) { if (isset($preciselyHandledPages[$rel])) { @@ -378,7 +355,6 @@ final class Graph } if (! isset($this->fileIds[$rel])) { if (! is_file($this->projectRoot.'/'.$rel)) { - // Deleted file unknown to the graph — no edge ever pointed to it. continue; } @@ -396,9 +372,6 @@ final class Graph $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 !== []) { foreach ($this->edges as $testFile => $ids) { if (isset($affectedSet[$testFile])) { @@ -509,9 +482,6 @@ final class Graph $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']) { 0 => TestStatus::success(), 1 => TestStatus::skipped($r['message']), @@ -585,7 +555,6 @@ final class Graph $this->baselines[$branch]['tree'] = $tree; } - // Edges and tree snapshot stay intact; only the run-state is reset. public function clearResults(string $branch): void { $this->ensureBaseline($branch); @@ -641,7 +610,6 @@ final class Graph $this->link($testFile, $source); } - // Deduplicate ids for this test. $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> $fileToComponents */ @@ -1086,7 +1053,6 @@ final class Graph return TableExtractor::fromMigrationSource($content); } - // Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux. private function componentForInertiaPage(string $rel): ?string { foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { @@ -1275,8 +1241,6 @@ final class Graph 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 { if ($path === '' || $path === 'unknown') { @@ -1290,8 +1254,7 @@ final class Graph $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) - || (strlen($path) >= 2 && $path[1] === ':'); // Windows drive - + || (strlen($path) >= 2 && $path[1] === ':'); if ($isAbsolute) { $real = @realpath($path); @@ -1303,7 +1266,6 @@ final class Graph return null; } - // Always forward slashes — git always uses them; Windows backslashes would never match. $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); } else { $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); diff --git a/src/Plugins/Tia/JsImportParser.php b/src/Plugins/Tia/JsImportParser.php index 5f9ee53e..6df5606e 100644 --- a/src/Plugins/Tia/JsImportParser.php +++ b/src/Plugins/Tia/JsImportParser.php @@ -16,12 +16,6 @@ final class JsImportParser 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> */ 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 - * `