This commit is contained in:
nuno maduro
2026-04-22 08:42:32 -07:00
parent 68527c996f
commit 660b57b365
7 changed files with 215 additions and 30 deletions

View File

@ -30,7 +30,7 @@ use Throwable;
* ----- * -----
* - **Record** — no graph (or fingerprint / recording commit drifted). The * - **Record** — no graph (or fingerprint / recording commit drifted). The
* full suite runs with PCOV / Xdebug capture per test; the resulting * 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 * - **Replay** — graph valid. We diff the working tree against the recording
* commit, intersect changed files with graph edges, and run only the * commit, intersect changed files with graph edges, and run only the
* affected tests. Newly-added tests unknown to the graph are always * 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 * - **Worker, record**: boots through `bin/worker.php`, which re-runs
* `CallsHandleArguments`. We detect the worker context + recording flag, * `CallsHandleArguments`. We detect the worker context + recording flag,
* activate the `Recorder`, and flush the partial graph on `terminate()` * activate the `Recorder`, and flush the partial graph on `terminate()`
* into `.temp/tia/worker-edges-<TEST_TOKEN>.json`. * into `.pest/tia/worker-edges-<TEST_TOKEN>.json`.
* - **Worker, replay**: nothing to do; args already narrowed. * - **Worker, replay**: nothing to do; args already narrowed.
* *
* Guardrails * Guardrails
@ -86,7 +86,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* State keys under which TIA persists its blobs. Kept here as constants * State keys under which TIA persists its blobs. Kept here as constants
* (rather than scattered strings) so the storage layout is visible in * (rather than scattered strings) so the storage layout is visible in
* one place, and so `CoverageMerger` can reference the same keys. All * 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. * because the directory already namespaces them.
*/ */
public const string KEY_GRAPH = 'graph.json'; 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). * 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'; private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';

View File

@ -17,7 +17,7 @@ use Symfony\Component\Process\Process;
* *
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow * Storage: **workflow artifacts**, not releases. A dedicated CI workflow
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full * (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` + * artifact (`pest-tia-baseline`) containing `graph.json` +
* `coverage.bin`. On dev * `coverage.bin`. On dev
* machines, this class finds the latest successful run of that workflow * machines, this class finds the latest successful run of that workflow
@ -285,10 +285,15 @@ jobs:
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: php artisan key:generate - run: php artisan key:generate
- run: ./vendor/bin/pest --parallel --tia --coverage - 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 - uses: actions/upload-artifact@v4
with: with:
name: pest-tia-baseline name: pest-tia-baseline
path: vendor/pestphp/pest/.temp/tia/ path: .pest-tia-baseline/
retention-days: 30 retention-days: 30
YAML; YAML;
} }
@ -311,10 +316,15 @@ jobs:
with: { php-version: '8.4', coverage: xdebug } with: { php-version: '8.4', coverage: xdebug }
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pest --parallel --tia --coverage - 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 - uses: actions/upload-artifact@v4
with: with:
name: pest-tia-baseline name: pest-tia-baseline
path: vendor/pestphp/pest/.temp/tia/ path: .pest-tia-baseline/
retention-days: 30 retention-days: 30
YAML; YAML;
} }

View File

@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract; use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\TestSuite;
/** /**
* Plugin-level container registrations for TIA. Runs as part of Kernel's * 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 * TIA's per-project state directory. Default layout is
* in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad * `~/.pest/tia/<project-key>/` so the graph survives `composer
* alongside PHPUnit's unrelated files (coverage.php, test-results, * install`, stays out of the project tree, and is naturally shared
* code-coverage/) and makes the CI artifact-upload path a single * across worktrees of the same repo. See {@see Storage} for the key
* directory instead of a list of individual files. * derivation and the home-dir-missing fallback.
*/ */
private function tempDir(): string private function tempDir(): string
{ {
return __DIR__ $testSuite = $this->container->get(TestSuite::class);
.DIRECTORY_SEPARATOR.'..' assert($testSuite instanceof TestSuite);
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..' return Storage::tempDir($testSuite->rootPath);
.DIRECTORY_SEPARATOR.'.temp'
.DIRECTORY_SEPARATOR.'tia';
} }
} }

170
src/Plugins/Tia/Storage.php Normal file
View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Resolves TIA's on-disk state directory.
*
* Default location: `$HOME/.pest/tia/<project-key>/`. 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
* `<projectRoot>/.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, '-');
}
}

View File

@ -8,6 +8,7 @@ use Composer\XdebugHandler\XdebugHandler;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/** /**
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the * 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 private static function tiaWillReplay(string $projectRoot): bool
{ {
$path = self::graphPath(); $path = self::graphPath($projectRoot);
if (! is_file($path)) { if (! is_file($path)) {
return false; return false;
@ -165,15 +166,13 @@ final class XdebugGuard
} }
/** /**
* On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()` * On-disk location of the TIA graph — delegates to {@see Storage} so
* so both writer and reader stay in sync without a runtime container * the writer (TIA's bootstrapper) and this reader stay in sync
* lookup (the container isn't booted yet at this point). * 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) return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
.DIRECTORY_SEPARATOR.'.temp'
.DIRECTORY_SEPARATOR.'tia'
.DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
} }
} }

View File

@ -13,7 +13,7 @@ test('structural drift discards the graph entirely', function () {
tiaScenario(function (Sandbox $sandbox) { tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']); $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 = json_decode((string) file_get_contents($graphPath), true);
$graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32); $graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32);
file_put_contents($graphPath, json_encode($graph)); file_put_contents($graphPath, json_encode($graph));
@ -29,7 +29,7 @@ test('environmental drift keeps edges, drops results', function () {
tiaScenario(function (Sandbox $sandbox) { tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']); $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 = json_decode((string) file_get_contents($graphPath), true);
$edgeCountBefore = count($graph['edges']); $edgeCountBefore = count($graph['edges']);

View File

@ -101,6 +101,13 @@ final class Sandbox
'GITHUB_ACTIONS' => '', 'GITHUB_ACTIONS' => '',
'GITLAB_CI' => '', 'GITLAB_CI' => '',
'CIRCLECI' => '', '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); $process->setTimeout(120.0);
@ -114,7 +121,7 @@ final class Sandbox
*/ */
public function graph(): ?array 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)) { if (! is_file($path)) {
return null; return null;