This commit is contained in:
nuno maduro
2026-04-21 08:15:24 -07:00
parent ed399af43e
commit 2941f9821f
9 changed files with 114 additions and 23 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.json`. * `test → [source_file, …]` edges land in `.temp/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-<TEST_TOKEN>.json`. * into `.temp/tia/worker-edges-<TEST_TOKEN>.json`.
* - **Worker, replay**: nothing to do; args already narrowed. * - **Worker, replay**: nothing to do; args already narrowed.
* *
* Guardrails * Guardrails
@ -77,29 +77,31 @@ 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. * one place, and so `CoverageMerger` can reference the same keys. All
* files live under `.temp/tia/` — the `tia-` filename prefix is gone
* because the directory already namespaces them.
*/ */
public const string KEY_GRAPH = 'tia.json'; public const string KEY_GRAPH = 'graph.json';
public const string KEY_AFFECTED = 'tia-affected.json'; public const string KEY_AFFECTED = 'affected.json';
private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-'; private const string KEY_WORKER_EDGES_PREFIX = 'worker-edges-';
private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-'; private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
/** /**
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage` * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
* run. Stored as bytes so the backend stays JSON/file-agnostic — the * run. Stored as bytes so the backend stays JSON/file-agnostic — the
* merger un/serialises rather than `require`-ing a PHP file. * merger un/serialises rather than `require`-ing a PHP file.
*/ */
public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin'; public const string KEY_COVERAGE_CACHE = 'coverage.bin';
/** /**
* Marker key dropped by `Tia` to tell `Support\Coverage` to apply the * Marker key dropped by `Tia` to tell `Support\Coverage` to apply the
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its * merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
* current (narrow) behaviour. * current (narrow) behaviour.
*/ */
public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker'; public const string KEY_COVERAGE_MARKER = 'coverage.marker';
/** /**
* Global flag toggled by the parent process so workers know to record. * Global flag toggled by the parent process so workers know to record.
@ -108,7 +110,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 `.temp/tia/affected.json`.
*/ */
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';

View File

@ -16,8 +16,9 @@ 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 resulting `tia.json` + * suite under `--tia` and uploads the `.temp/tia/` directory as a named
* `tia-coverage.bin` as a named artifact (`pest-tia-baseline`). On dev * artifact (`pest-tia-baseline`) containing `graph.json` +
* `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
* and downloads the artifact via `gh`. * and downloads the artifact via `gh`.
* *
@ -48,7 +49,7 @@ final class BaselineSync
/** /**
* Artifact name the workflow uploads under. The artifact is a zip * Artifact name the workflow uploads under. The artifact is a zip
* containing `tia.json` (always) + `tia-coverage.bin` (optional). * containing `graph.json` (always) + `coverage.bin` (optional).
*/ */
private const string ARTIFACT_NAME = 'pest-tia-baseline'; private const string ARTIFACT_NAME = 'pest-tia-baseline';
@ -87,9 +88,7 @@ final class BaselineSync
$payload = $this->download($repo); $payload = $this->download($repo);
if ($payload === null) { if ($payload === null) {
$this->output->writeln( $this->emitPublishInstructions($repo);
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
);
return false; return false;
} }
@ -110,6 +109,48 @@ final class BaselineSync
return true; return true;
} }
/**
* Prints actionable instructions for publishing a first baseline when
* the consumer-side fetch finds nothing. Without this, the "no
* baseline yet" state is a dead-end for users — they see the message
* and have to guess what to do next.
*/
private function emitPublishInstructions(string $repo): void
{
$this->output->writeln([
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
'',
' To share the baseline with your team, add this workflow to the repo:',
'',
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
'',
' name: TIA Baseline',
' on:',
' push: { branches: [main] }',
' schedule: [{ cron: \'0 3 * * *\' }]',
' workflow_dispatch:',
' jobs:',
' baseline:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: actions/checkout@v4',
' with: { fetch-depth: 0 }',
' - uses: shivammathur/setup-php@v2',
' with: { php-version: \'8.4\', coverage: xdebug }',
' - run: composer install --no-interaction --prefer-dist',
' - run: ./vendor/bin/pest --parallel --tia --coverage',
' - uses: actions/upload-artifact@v4',
' with:',
' name: pest-tia-baseline',
' path: vendor/pestphp/pest/.temp/tia/',
' retention-days: 30',
'',
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
'',
]);
}
/** /**
* Parses `.git/config` for the `origin` remote and extracts * Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box. * `org/repo`. Supports the two URL flavours git emits out of the box.

View File

@ -32,9 +32,11 @@ final readonly class Bootstrapper implements BootstrapperContract
} }
/** /**
* Resolve Pest's `.temp/` directory relative to this file so TIA's * TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob
* caches share the same location as the rest of Pest's transient * in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad
* state (PHPUnit result cache, coverage PHP dumps, etc.). * 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.
*/ */
private function tempDir(): string private function tempDir(): string
{ {
@ -42,6 +44,7 @@ final readonly class Bootstrapper implements BootstrapperContract
.DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'.temp'; .DIRECTORY_SEPARATOR.'.temp'
.DIRECTORY_SEPARATOR.'tia';
} }
} }

View File

@ -39,7 +39,7 @@ interface State
/** /**
* Returns every key whose name starts with `$prefix`. Used to collect * Returns every key whose name starts with `$prefix`. Used to collect
* paratest worker partials (`tia-worker-<token>.json`, etc.) without * paratest worker partials (`worker-edges-<token>.json`, etc.) without
* exposing backend-specific glob semantics. * exposing backend-specific glob semantics.
* *
* @return list<string> * @return list<string>

View File

@ -16,7 +16,7 @@ final readonly class Fingerprint
{ {
// Bump this whenever the set of inputs or the hash algorithm changes, so // Bump this whenever the set of inputs or the hash algorithm changes, so
// older graphs are invalidated automatically. // older graphs are invalidated automatically.
private const int SCHEMA_VERSION = 2; private const int SCHEMA_VERSION = 3;
/** /**
* @return array<string, int|string|null> * @return array<string, int|string|null>
@ -26,6 +26,13 @@ final readonly class Fingerprint
return [ return [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
'php' => PHP_VERSION, 'php' => PHP_VERSION,
// Loaded extensions + their versions. Guards against the
// "recorded without pcov/xdebug → subsequent run has the
// driver but graph has no edges" trap where the fingerprint
// matches but the graph is effectively empty. Sorted so two
// processes with the same extensions in different load order
// still produce the same hash.
'extensions' => self::extensionsFingerprint(),
'pest' => self::readPestVersion($projectRoot), 'pest' => self::readPestVersion($projectRoot),
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
@ -41,6 +48,27 @@ final readonly class Fingerprint
]; ];
} }
/**
* Deterministic hash of the PHP extension set: `ext-name@version` pairs
* sorted alphabetically and joined. Captures both presence (pcov
* disappeared? graph must rebuild) and version changes (xdebug minor
* bump with coverage-mode semantics).
*/
private static function extensionsFingerprint(): string
{
$extensions = get_loaded_extensions();
sort($extensions);
$parts = [];
foreach ($extensions as $name) {
$version = phpversion($name);
$parts[] = $name.'@'.($version === false ? '?' : $version);
}
return hash('xxh128', implode("\n", $parts));
}
/** /**
* @param array<string, mixed> $a * @param array<string, mixed> $a
* @param array<string, mixed> $b * @param array<string, mixed> $b

View File

@ -60,6 +60,10 @@ final readonly class Laravel implements WatchDefault
// Blade templates — compiled to cache, source file not executed. // Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$featurePath], 'resources/views/**/*.blade.php' => [$featurePath],
// Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$featurePath],
'resources/views/emails/**/*.blade.php' => [$featurePath],
// Translations — JSON translations read via file_get_contents, // Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot). // PHP translations loaded via include (but during boot).

View File

@ -29,6 +29,10 @@ final readonly class Livewire implements WatchDefault
// Livewire views live alongside Blade views or in a dedicated dir. // 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],
// Livewire JS interop / Alpine plugins. // Livewire JS interop / Alpine plugins.
'resources/js/**/*.js' => [$testPath], 'resources/js/**/*.js' => [$testPath],

View File

@ -25,9 +25,14 @@ final readonly class Php implements WatchDefault
return [ return [
// Environment files — can change DB drivers, feature flags, // Environment files — can change DB drivers, feature flags,
// queue connections, etc. Not PHP, not fingerprinted. // 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],
// Docker / CI — can affect integration test infrastructure. // Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath], 'docker-compose.yml' => [$testPath],

View File

@ -46,7 +46,11 @@ final readonly class Symfony implements WatchDefault
'src/Kernel.php' => [$testPath], 'src/Kernel.php' => [$testPath],
// Migrations — run during setUp (before coverage window). // 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],
// Twig templates — compiled, source not PHP-executed. // Twig templates — compiled, source not PHP-executed.
'templates/**/*.html.twig' => [$testPath], 'templates/**/*.html.twig' => [$testPath],