From 660b57b3654f11e0f2d2c381804aae252b5c8736 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 22 Apr 2026 08:42:32 -0700 Subject: [PATCH] wip --- src/Plugins/Tia.php | 8 +- src/Plugins/Tia/BaselineSync.php | 16 ++- src/Plugins/Tia/Bootstrapper.php | 21 ++-- src/Plugins/Tia/Storage.php | 170 +++++++++++++++++++++++++++++ src/Support/XdebugGuard.php | 17 ++- tests-tia/FingerprintDriftTest.php | 4 +- tests-tia/Support/Sandbox.php | 9 +- 7 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 src/Plugins/Tia/Storage.php diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 9a57f5ab..df3093b5 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -30,7 +30,7 @@ use Throwable; * ----- * - **Record** — no graph (or fingerprint / recording commit drifted). The * full suite runs with PCOV / Xdebug capture per test; the resulting - * `test → [source_file, …]` edges land in `.temp/tia/graph.json`. + * `test → [source_file, …]` edges land in `.pest/tia/graph.json`. * - **Replay** — graph valid. We diff the working tree against the recording * commit, intersect changed files with graph edges, and run only the * affected tests. Newly-added tests unknown to the graph are always @@ -53,7 +53,7 @@ use Throwable; * - **Worker, record**: boots through `bin/worker.php`, which re-runs * `CallsHandleArguments`. We detect the worker context + recording flag, * activate the `Recorder`, and flush the partial graph on `terminate()` - * into `.temp/tia/worker-edges-.json`. + * into `.pest/tia/worker-edges-.json`. * - **Worker, replay**: nothing to do; args already narrowed. * * Guardrails @@ -86,7 +86,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in * one place, and so `CoverageMerger` can reference the same keys. All - * files live under `.temp/tia/` — the `tia-` filename prefix is gone + * files live under `.pest/tia/` — the `tia-` filename prefix is gone * because the directory already namespaces them. */ public const string KEY_GRAPH = 'graph.json'; @@ -126,7 +126,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable /** * Global flag that tells workers to install the TIA filter (replay mode). - * Workers read the affected set from `.temp/tia/affected.json`. + * Workers read the affected set from `.pest/tia/affected.json`. */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 399bb72e..0c653fc0 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -17,7 +17,7 @@ use Symfony\Component\Process\Process; * * Storage: **workflow artifacts**, not releases. A dedicated CI workflow * (conventionally `.github/workflows/tia-baseline.yml`) runs the full - * suite under `--tia` and uploads the `.temp/tia/` directory as a named + * suite under `--tia` and uploads the `.pest/tia/` directory as a named * artifact (`pest-tia-baseline`) containing `graph.json` + * `coverage.bin`. On dev * machines, this class finds the latest successful run of that workflow @@ -285,10 +285,15 @@ jobs: - run: composer install --no-interaction --prefer-dist - run: php artisan key:generate - run: ./vendor/bin/pest --parallel --tia --coverage + - name: Stage baseline for upload + shell: bash + run: | + mkdir -p .pest-tia-baseline + cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/ - uses: actions/upload-artifact@v4 with: name: pest-tia-baseline - path: vendor/pestphp/pest/.temp/tia/ + path: .pest-tia-baseline/ retention-days: 30 YAML; } @@ -311,10 +316,15 @@ jobs: with: { php-version: '8.4', coverage: xdebug } - run: composer install --no-interaction --prefer-dist - run: ./vendor/bin/pest --parallel --tia --coverage + - name: Stage baseline for upload + shell: bash + run: | + mkdir -p .pest-tia-baseline + cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/ - uses: actions/upload-artifact@v4 with: name: pest-tia-baseline - path: vendor/pestphp/pest/.temp/tia/ + path: .pest-tia-baseline/ retention-days: 30 YAML; } diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php index 5585e945..78c1b961 100644 --- a/src/Plugins/Tia/Bootstrapper.php +++ b/src/Plugins/Tia/Bootstrapper.php @@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia; use Pest\Contracts\Bootstrapper as BootstrapperContract; use Pest\Plugins\Tia\Contracts\State; use Pest\Support\Container; +use Pest\TestSuite; /** * Plugin-level container registrations for TIA. Runs as part of Kernel's @@ -32,19 +33,17 @@ final readonly class Bootstrapper implements BootstrapperContract } /** - * TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob - * in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad - * alongside PHPUnit's unrelated files (coverage.php, test-results, - * code-coverage/) and makes the CI artifact-upload path a single - * directory instead of a list of individual files. + * 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 { - return __DIR__ - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'.temp' - .DIRECTORY_SEPARATOR.'tia'; + $testSuite = $this->container->get(TestSuite::class); + assert($testSuite instanceof TestSuite); + + return Storage::tempDir($testSuite->rootPath); } } diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php new file mode 100644 index 00000000..d26e6fa4 --- /dev/null +++ b/src/Plugins/Tia/Storage.php @@ -0,0 +1,170 @@ +/`. Keeping state in the + * user's home directory (rather than under `vendor/pestphp/pest/`) means: + * + * - `composer install` / path-repo reinstalls don't wipe the graph. + * - The state lives outside the project tree, so there is nothing for + * users to gitignore or accidentally commit. + * - Multiple worktrees of the same repo share one cache naturally. + * + * The project key is derived from the git origin URL when available — a + * CI workflow running on `github.com/org/repo` and a developer's clone + * of the same remote both compute the *same* key, which is what lets the + * CI-uploaded baseline line up with the dev-side reader. When the project + * is not in git, the key falls back to a hash of the absolute path so + * unrelated projects on the same machine stay isolated. + * + * When no home directory is resolvable (`HOME` / `USERPROFILE` both + * unset — the tests-tia sandboxes strip these deliberately, and some + * locked-down CI environments do the same), state falls back to + * `/.pest/tia/`. That path is project-local but still + * survives composer installs, so the degradation is graceful. + * + * @internal + */ +final class Storage +{ + /** + * Directory where TIA's State blobs live for `$projectRoot`. + */ + public static function tempDir(string $projectRoot): string + { + $home = self::homeDir(); + + if ($home === null) { + return $projectRoot + .DIRECTORY_SEPARATOR.'.pest' + .DIRECTORY_SEPARATOR.'tia'; + } + + return $home + .DIRECTORY_SEPARATOR.'.pest' + .DIRECTORY_SEPARATOR.'tia' + .DIRECTORY_SEPARATOR.self::projectKey($projectRoot); + } + + /** + * 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 + { + foreach (['HOME', 'USERPROFILE'] as $key) { + $value = getenv($key); + + if (is_string($value) && $value !== '' && is_dir($value)) { + return rtrim($value, '/\\'); + } + } + + return null; + } + + /** + * 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` + * 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 + { + $origin = self::originIdentity($projectRoot); + + $realpath = @realpath($projectRoot); + $input = $origin ?? ($realpath === false ? $projectRoot : $realpath); + + $hash = substr(hash('sha256', $input), 0, 16); + $slug = self::slug(basename($projectRoot)); + + 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 + { + $url = self::rawOriginUrl($projectRoot); + + if ($url === null) { + return null; + } + + // git@host:org/repo(.git) + if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) { + return strtolower($m[1].'/'.$m[2]); + } + + // scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file + if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { + 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); + } + + private static function rawOriginUrl(string $projectRoot): ?string + { + $config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; + + if (! is_file($config)) { + return null; + } + + $raw = @file_get_contents($config); + + if ($raw === false) { + return null; + } + + if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) { + return trim($match[1]); + } + + 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 + { + $slug = strtolower($name); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; + + return trim($slug, '-'); + } +} diff --git a/src/Support/XdebugGuard.php b/src/Support/XdebugGuard.php index 539a90ea..58cf628a 100644 --- a/src/Support/XdebugGuard.php +++ b/src/Support/XdebugGuard.php @@ -8,6 +8,7 @@ use Composer\XdebugHandler\XdebugHandler; use Pest\Plugins\Tia; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; +use Pest\Plugins\Tia\Storage; /** * Re-execs the PHP process without Xdebug on TIA replay runs, matching the @@ -140,7 +141,7 @@ final class XdebugGuard */ private static function tiaWillReplay(string $projectRoot): bool { - $path = self::graphPath(); + $path = self::graphPath($projectRoot); if (! is_file($path)) { return false; @@ -165,15 +166,13 @@ final class XdebugGuard } /** - * On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()` - * so both writer and reader stay in sync without a runtime container - * lookup (the container isn't booted yet at this point). + * On-disk location of the TIA graph — delegates to {@see Storage} so + * the writer (TIA's bootstrapper) and this reader stay in sync + * without a runtime container lookup (the container isn't booted yet + * at this point). */ - private static function graphPath(): string + private static function graphPath(string $projectRoot): string { - return dirname(__DIR__, 2) - .DIRECTORY_SEPARATOR.'.temp' - .DIRECTORY_SEPARATOR.'tia' - .DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; + return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; } } diff --git a/tests-tia/FingerprintDriftTest.php b/tests-tia/FingerprintDriftTest.php index b7c5fccb..5ac2df51 100644 --- a/tests-tia/FingerprintDriftTest.php +++ b/tests-tia/FingerprintDriftTest.php @@ -13,7 +13,7 @@ test('structural drift discards the graph entirely', function () { tiaScenario(function (Sandbox $sandbox) { $sandbox->pest(['--tia']); - $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graphPath = $sandbox->path().'/.pest/tia/graph.json'; $graph = json_decode((string) file_get_contents($graphPath), true); $graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32); file_put_contents($graphPath, json_encode($graph)); @@ -29,7 +29,7 @@ test('environmental drift keeps edges, drops results', function () { tiaScenario(function (Sandbox $sandbox) { $sandbox->pest(['--tia']); - $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graphPath = $sandbox->path().'/.pest/tia/graph.json'; $graph = json_decode((string) file_get_contents($graphPath), true); $edgeCountBefore = count($graph['edges']); diff --git a/tests-tia/Support/Sandbox.php b/tests-tia/Support/Sandbox.php index 4c3361a2..33843f98 100644 --- a/tests-tia/Support/Sandbox.php +++ b/tests-tia/Support/Sandbox.php @@ -101,6 +101,13 @@ final class Sandbox 'GITHUB_ACTIONS' => '', 'GITLAB_CI' => '', 'CIRCLECI' => '', + // Force TIA's Storage to fall back to the sandbox-local + // `.pest/tia/` layout. Without this, every sandbox run + // would dump state into the developer's real home dir + // (`~/.pest/tia/`), polluting it and making tests + // non-hermetic. + 'HOME' => '', + 'USERPROFILE' => '', ], ); $process->setTimeout(120.0); @@ -114,7 +121,7 @@ final class Sandbox */ public function graph(): ?array { - $path = $this->path.'/vendor/pestphp/pest/.temp/tia/graph.json'; + $path = $this->path.'/.pest/tia/graph.json'; if (! is_file($path)) { return null;