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
* 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
* 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-<TEST_TOKEN>.json`.
* into `.temp/tia/worker-edges-<TEST_TOKEN>.json`.
* - **Worker, replay**: nothing to do; args already narrowed.
*
* Guardrails
@ -77,29 +77,31 @@ 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.
* 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`
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
* 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
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
* 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.
@ -108,7 +110,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 `.temp/tia/affected.json`.
*/
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
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
* suite under `--tia` and uploads the resulting `tia.json` +
* `tia-coverage.bin` as a named artifact (`pest-tia-baseline`). On dev
* suite under `--tia` and uploads the `.temp/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
* and downloads the artifact via `gh`.
*
@ -48,7 +49,7 @@ final class BaselineSync
/**
* 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';
@ -87,9 +88,7 @@ final class BaselineSync
$payload = $this->download($repo);
if ($payload === null) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
);
$this->emitPublishInstructions($repo);
return false;
}
@ -110,6 +109,48 @@ final class BaselineSync
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
* `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
* caches share the same location as the rest of Pest's transient
* state (PHPUnit result cache, coverage PHP dumps, etc.).
* 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.
*/
private function tempDir(): string
{
@ -42,6 +44,7 @@ final readonly class Bootstrapper implements BootstrapperContract
.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
* paratest worker partials (`tia-worker-<token>.json`, etc.) without
* paratest worker partials (`worker-edges-<token>.json`, etc.) without
* exposing backend-specific glob semantics.
*
* @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
// older graphs are invalidated automatically.
private const int SCHEMA_VERSION = 2;
private const int SCHEMA_VERSION = 3;
/**
* @return array<string, int|string|null>
@ -26,6 +26,13 @@ final readonly class Fingerprint
return [
'schema' => self::SCHEMA_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),
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
'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> $b

View File

@ -60,6 +60,10 @@ final readonly class Laravel implements WatchDefault
// Blade templates — compiled to cache, source file not executed.
'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,
// 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.
'resources/views/livewire/**/*.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.
'resources/js/**/*.js' => [$testPath],

View File

@ -25,9 +25,14 @@ final readonly class Php implements WatchDefault
return [
// 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.testing' => [$testPath],
'.env.local' => [$testPath],
'.env.*.local' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath],

View File

@ -46,7 +46,11 @@ final readonly class Symfony implements WatchDefault
'src/Kernel.php' => [$testPath],
// Migrations — run during setUp (before coverage window).
// DoctrineMigrationsBundle's default is `migrations/` at the
// project root; many Symfony projects relocate to
// `src/Migrations/` — both covered.
'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath],
// Twig templates — compiled, source not PHP-executed.
'templates/**/*.html.twig' => [$testPath],