Compare commits

...

8 Commits

Author SHA1 Message Date
856a370032 style 2026-04-21 09:44:26 -07:00
e24882c486 wip 2026-04-21 09:41:19 -07:00
51fc380789 wip 2026-04-21 09:40:01 -07:00
f6609f4039 wip 2026-04-21 08:36:41 -07:00
2941f9821f wip 2026-04-21 08:15:24 -07:00
ed399af43e wip 2026-04-21 07:41:50 -07:00
0d66dc4322 chore: removes https 2026-04-21 07:26:19 -07:00
7e4280bf83 chore: improves feedback 2026-04-21 07:13:08 -07:00
24 changed files with 563 additions and 457 deletions

View File

@ -19,7 +19,7 @@
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"nunomaduro/collision": "^8.9.3",
"nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2",

View File

@ -9,8 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Preset;
use Pest\Support\Container;
use Pest\Support\ChainableClosure;
use Pest\Support\Container;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\Support\Shell;

View File

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Pest\Plugins;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use PHPUnit\Framework\TestStatus\TestStatus;
use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State;
@ -19,6 +19,7 @@ use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
@ -29,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
@ -52,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
@ -73,34 +74,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REBUILD_OPTION = '--tia-rebuild';
private const string PUBLISH_OPTION = '--tia-publish';
/**
* 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.
@ -109,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';
@ -152,12 +153,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private array $cachedAssertionsByTestId = [];
/**
* Captured at replay setup so the end-of-run summary can report the
* scope of the changes that drove the run.
*/
private int $changedFileCount = 0;
/**
* Holds the graph during replay so `beforeEach` can look up cached
* results without re-loading from disk on every test.
@ -178,12 +173,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private array $affectedFiles = [];
private static function workerEdgesKey(string $token): string
private function workerEdgesKey(string $token): string
{
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
}
private static function workerResultsKey(string $token): string
private function workerResultsKey(string $token): string
{
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
}
@ -247,7 +242,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
public function getCachedResult(string $filename, string $testId): ?TestStatus
{
if ($this->replayGraph === null) {
if (! $this->replayGraph instanceof Graph) {
return null;
}
@ -276,7 +271,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// branch (falls back to main if branch is fresh).
$result = $this->replayGraph->getResult($this->branch, $testId);
if ($result !== null) {
if ($result instanceof TestStatus) {
$this->replayedCount++;
// Cache the assertion count alongside the status so `Testable`
// can emit the exact `addToAssertionCount()` at replay time
@ -313,16 +308,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
// `--tia-publish` is its own entry point: it neither records nor
// replays, it just uploads whatever baseline is already on disk
// and exits. Handled before the usual `--tia` gating so users can
// publish without also triggering a suite run.
if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) {
$projectRoot = TestSuite::getInstance()->rootPath;
exit($this->baselineSync->publish($projectRoot));
}
$enabled = $this->hasArgument(self::OPTION, $arguments);
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
@ -366,7 +351,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// the graph lands with results on first write — otherwise the next
// run would load a graph with edges but empty results, miss the
// cache for every test, and look pointlessly slow.
if (Parallel::isWorker() && ($this->replayGraph !== null || $this->recordingActive)) {
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
$this->flushWorkerReplay();
}
@ -391,7 +376,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
if (Parallel::isWorker()) {
$this->flushWorkerPartial($projectRoot, $perTest);
$this->flushWorkerPartial($perTest);
$recorder->reset();
$this->coverageCollector->reset();
@ -460,7 +445,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($this->replayRan) {
$this->bumpRecordedSha();
$this->emitReplaySummary();
}
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
@ -519,6 +503,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$finalised[$testFile] = array_keys($sourceSet);
}
// Empty-edges guard: if every worker returned no edges it almost
// always means the coverage driver wasn't loaded in the workers
// (common footgun with custom PHP ini scan dirs, Herd profiles,
// stripped CI runners). Writing the empty graph would silently
// seed a broken baseline; fail loud instead.
if ($finalised === []) {
$this->output->writeln([
'',
' <fg=white;bg=red> ERROR </> TIA recorded zero edges — coverage driver likely missing.',
' Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and retry.',
'',
]);
return $exitCode;
}
$graph->replaceEdges($finalised);
$graph->pruneMissingTests();
@ -543,6 +543,56 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode;
}
/**
* Compares a loaded graph's fingerprint to the current one and decides
* how much of the graph is still usable.
*
* - **Structural drift** (composer.lock, Pest.php, factory codegen,
* schema bump): edges themselves are potentially wrong → discard
* the whole graph + coverage cache and return null. Caller falls
* through to record mode.
* - **Environmental drift** (PHP minor, extension set, Pest version):
* edges describe the code correctly; only the cached per-test
* results were captured against a different runtime and might not
* reproduce. Drop `baselines[branch].results` + coverage cache,
* bump the fingerprint to the current env, persist. Caller uses
* the graph for edges; results refill naturally during this run's
* replay (every test misses cache, runs normally, seeds results).
* - **Match**: return the graph untouched.
*
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/
private function reconcileFingerprint(Graph $graph, array $current): ?Graph
{
$stored = $graph->fingerprint();
if (! Fingerprint::structuralMatches($stored, $current)) {
$this->output->writeln(
' <fg=yellow>TIA</> graph structure outdated — rebuilding.',
);
$this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE);
return null;
}
$drift = Fingerprint::environmentalDrift($stored, $current);
if ($drift !== []) {
$this->output->writeln(sprintf(
' <fg=yellow>TIA</> env differs from baseline (%s) — results dropped, edges reused.',
implode(', ', $drift),
));
$graph->clearResults($this->branch);
$graph->setFingerprint($current);
$this->saveGraph($graph);
$this->state->delete(self::KEY_COVERAGE_CACHE);
}
return $graph;
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
@ -563,11 +613,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
$this->output->writeln(
' <fg=yellow>TIA</> environment fingerprint changed — graph will be rebuilt.',
);
$graph = null;
if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint);
}
if ($graph instanceof Graph) {
@ -587,19 +634,11 @@ 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 re-validated against the local env.
if ($graph === null && ! $forceRebuild) {
if ($this->baselineSync->fetchIfAvailable($projectRoot)) {
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
$this->output->writeln(
' <fg=yellow>TIA</> pulled baseline fingerprint mismatch — discarding.',
);
$this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE);
$graph = null;
}
// the graph is re-read and reconciled against the local env.
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) {
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint);
}
}
@ -615,14 +654,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// report collapses to near-zero coverage. Fall back to recording
// (full suite) to seed the cache for next time.
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
return $this->enterRecordMode($projectRoot, $arguments);
return $this->enterRecordMode($arguments);
}
if ($graph instanceof Graph) {
return $this->enterReplayMode($graph, $projectRoot, $arguments);
}
return $this->enterRecordMode($projectRoot, $arguments);
return $this->enterRecordMode($arguments);
}
/**
@ -737,20 +776,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$affected = $changed === [] ? [] : $graph->affected($changed);
$this->changedFileCount = count($changed);
$affectedSet = array_fill_keys($affected, true);
$this->replayRan = true;
$this->replayGraph = $graph;
$this->affectedFiles = $affectedSet;
$this->registerRecap();
if (! Parallel::isEnabled()) {
return $arguments;
}
// Parallel: persist affected set so workers can install the filter.
if (! $this->persistAffectedSet($projectRoot, $affected)) {
if (! $this->persistAffectedSet($affected)) {
$this->output->writeln(
' <fg=red>TIA</> failed to persist affected set — running full suite.',
);
@ -760,7 +799,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// Clear stale partials from a previous interrupted run so the merge
// pass doesn't pick up results from an unrelated invocation.
$this->purgeWorkerPartials($projectRoot);
$this->purgeWorkerPartials();
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
@ -770,7 +809,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/**
* @param array<int, string> $affected Project-relative paths.
*/
private function persistAffectedSet(string $projectRoot, array $affected): bool
private function persistAffectedSet(array $affected): bool
{
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
@ -785,7 +824,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function enterRecordMode(string $projectRoot, array $arguments): array
private function enterRecordMode(array $arguments): array
{
$recorder = $this->recorder;
@ -810,7 +849,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// recording. We only advertise the intent through a global.
// Clean up any stale partial files from a previous interrupted
// run so the merge step doesn't confuse itself.
$this->purgeWorkerPartials($projectRoot);
$this->purgeWorkerPartials();
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
@ -865,7 +904,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/**
* @param array<string, array<int, string>> $perTest
*/
private function flushWorkerPartial(string $projectRoot, array $perTest): void
private function flushWorkerPartial(array $perTest): void
{
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
@ -873,7 +912,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$this->state->write(self::workerEdgesKey($this->workerToken()), $json);
$this->state->write($this->workerEdgesKey($this->workerToken()), $json);
}
/**
@ -884,12 +923,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
}
private function purgeWorkerPartials(string $projectRoot): void
private function purgeWorkerPartials(): void
{
foreach ($this->collectWorkerEdgesPartials() as $key) {
$this->state->delete($key);
}
foreach ($this->collectWorkerReplayPartials() as $key) {
$this->state->delete($key);
}
@ -921,7 +959,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$this->state->write(self::workerResultsKey($this->workerToken()), $json);
$this->state->write($this->workerResultsKey($this->workerToken()), $json);
}
/**
@ -969,10 +1007,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var mixed $result */
foreach ($decoded['results'] as $testId => $result) {
if (! is_string($testId) || ! is_array($result)) {
if (! is_string($testId)) {
continue;
}
if (! is_array($result)) {
continue;
}
$normalised[$testId] = [
'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
'message' => is_string($result['message'] ?? null) ? $result['message'] : '',
@ -1057,23 +1097,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* git still reports them as modified.
*/
/**
* Prints the post-run TIA summary. Runs after the test report so the
* replayed count reflects what actually happened (cache hits counted
* inside `getCachedResult`) rather than a graph-level estimate that
* ignores any CLI path filter the user passed in.
* Hooks a recap callback into Collision's `DefaultPrinter` so TIA's
* counts ride along the "Tests: N passed (M assertions, ...)" line
* instead of printing on their own block. Collision joins each
* callback's return value with a gray `, ` separator, so we return
* a single fragment like `728 replayed via tia` (or nothing when
* there's no replay activity to report).
*/
private function emitReplaySummary(): void
private function registerRecap(): void
{
// `$executedCount` and `$replayedCount` are maintained in lockstep
// by `getCachedResult()` — every test id that hits that method bumps
// exactly one of them. Summing the two gives the test-method total
// that lines up with Pest's "Tests: N" banner directly above.
$this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
$this->changedFileCount,
$this->executedCount,
$this->replayedCount,
));
DefaultPrinter::addRecap(function (): string {
$fragments = [];
if ($this->executedCount > 0) {
$fragments[] = $this->executedCount.' affected';
}
if ($this->replayedCount > 0) {
$fragments[] = $this->replayedCount.' replayed';
}
return implode(', ', $fragments);
});
}
private function bumpRecordedSha(): void

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Composer\InstalledVersions;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
@ -15,15 +15,22 @@ use Symfony\Component\Process\Process;
* contributors and fresh CI workspaces start in replay mode instead of
* paying the ~30s record cost.
*
* The baseline lives as a GitHub Release with a fixed tag containing two
* assets — the graph JSON and the coverage cache. The repo is inferred
* from `.git/config`'s `origin` remote, so no per-project configuration
* is required. Non-GitHub remotes silently opt out.
* 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
* 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`.
*
* Fetching is attempted in order:
* 1. `gh release download` — uses the user's existing GitHub auth,
* works for private repos.
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
* Why artifacts, not releases:
* - No tag is created → no `push` event cascade into CI workflows.
* - No release event → no deploy workflows tied to `release:published`.
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
* a single floating tag.
* - Publishing is strictly CI-only: artifacts can't be produced from a
* developer's laptop. This enforces the "CI is the authoritative
* publisher" policy that local-publish paths would otherwise erode.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
@ -32,17 +39,23 @@ use Symfony\Component\Process\Process;
*
* @internal
*/
final class BaselineSync
final readonly class BaselineSync
{
/**
* Conventional tag the CI recipe publishes under. Not configurable for
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
* var is the likely escape hatch.
* Conventional workflow filename teams publish from. Not configurable
* for MVP — teams that outgrow the default can set
* `PEST_TIA_BASELINE_WORKFLOW` later.
*/
private const string RELEASE_TAG = 'pest-tia-baseline';
private const string WORKFLOW_FILE = 'tia-baseline.yml';
/**
* Asset filenames within the release — mirror the state keys so the
* Artifact name the workflow uploads under. The artifact is a zip
* containing `graph.json` (always) + `coverage.bin` (optional).
*/
private const string ARTIFACT_NAME = 'pest-tia-baseline';
/**
* Asset filenames inside the artifact — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
@ -50,16 +63,15 @@ final class BaselineSync
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
public function __construct(
private readonly State $state,
private readonly OutputInterface $output,
private readonly InputInterface $input,
private State $state,
private OutputInterface $output,
) {}
/**
* Attempts the full detect → prompt → download flow. Returns true when
* the graph blob was pulled and written to state. Coverage is best-
* effort: its absence doesn't fail the sync, since plain `--tia` (no
* `--coverage`) works fine without it.
* Detects the repo, fetches the latest baseline artifact, writes its
* contents into the TIA state store. Returns true when the graph blob
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it.
*/
public function fetchIfAvailable(string $projectRoot): bool
{
@ -69,231 +81,165 @@ final class BaselineSync
return false;
}
if (! $this->confirm($repo)) {
return false;
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
$repo,
));
$graphJson = $this->download($repo, self::GRAPH_ASSET);
$payload = $this->download($repo);
if ($graphJson === null) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
);
if ($payload === null) {
$this->emitPublishInstructions($repo);
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
return false;
}
// Coverage cache is optional. The baseline is useful even without
// it (plain `--tia` never needs it) so don't fail the whole sync
// just because this asset is missing or slow.
$coverageBin = $this->download($repo, self::COVERAGE_ASSET);
if ($coverageBin !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin);
if ($payload['coverage'] !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
));
return true;
}
/**
* Publishes the *local* baseline to GitHub Releases under the
* conventional tag, creating the release on first run or uploading
* into the existing one otherwise.
* Prints actionable instructions for publishing a first baseline when
* the consumer-side fetch finds nothing.
*
* Uploading from a developer workstation is intentionally discouraged
* — CI is the authoritative publisher because its environment is
* reproducible, its working tree is clean, and its result cache
* isn't contaminated by local flakiness. The prompt here defaults to
* *No* to keep this an explicit, opt-in action.
*
* Returns a CLI-style exit code so the caller can `exit()` on it.
* Behaviour splits on environment:
* - **CI:** a single line. The current run is almost certainly *the*
* publisher (it's what this workflow does by definition), so
* printing the whole recipe again is redundant and noisy.
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
* (`.env.example` copy + `artisan key:generate`) when the framework
* is present. Generic PHP projects get a slimmer skeleton.
*/
public function publish(string $projectRoot): int
private function emitPublishInstructions(string $repo): void
{
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
if ($this->isCi()) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
);
if ($graphBytes === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> no local baseline to publish.',
' Run <fg=cyan>./vendor/bin/pest --tia</> first to record one, then retry.',
'',
]);
return 1;
return;
}
$repo = $this->detectGitHubRepo($projectRoot);
$yaml = $this->isLaravel()
? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml();
if ($repo === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
' Publishing is supported only for GitHub-hosted projects.',
'',
]);
return 1;
}
if (! $this->commandExists('gh')) {
$this->output->writeln([
'',
' <fg=red>TIA</> publishing requires the <fg=cyan>gh</> CLI.',
' Install: <fg=gray>https://cli.github.com</>',
'',
]);
return 1;
}
$this->output->writeln([
$preamble = [
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
'',
' <fg=black;bg=yellow> WARNING </> Publishing local baselines is discouraged.',
' To share the baseline with your team, add this workflow to the repo:',
'',
' Local runs can bake flaky results or dirty working-tree state into the',
' baseline, which your team then replays. CI-published baselines are safer.',
' See <fg=gray>https://pestphp.com/docs/tia/ci</> for the recommended workflow.',
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
'',
]);
];
if (! $this->confirmPublish($repo)) {
$this->output->writeln(' <fg=yellow>TIA</> publish cancelled.');
$indentedYaml = array_map(
static fn (string $line): string => ' '.$line,
explode("\n", $yaml),
);
return 0;
}
$trailer = [
'',
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</>',
'',
];
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
$this->output->writeln(' <fg=red>TIA</> failed to create temp dir for upload.');
return 1;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
if (@file_put_contents($graphPath, $graphBytes) === false) {
$this->cleanup($tmpDir);
return 1;
}
$filesToUpload = [$graphPath];
$coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE);
if ($coverageBytes !== null) {
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
if (@file_put_contents($coveragePath, $coverageBytes) !== false) {
$filesToUpload[] = $coveragePath;
}
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> publishing to <fg=white>%s</> (tag <fg=white>%s</>)…',
$repo,
self::RELEASE_TAG,
));
$exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload);
$this->cleanup($tmpDir);
if ($exitCode !== 0) {
$this->output->writeln(' <fg=red>TIA</> <fg=cyan>gh release</> failed.');
return $exitCode;
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline published (%s).',
$this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))),
));
return 0;
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
}
/**
* Uploads into the existing release if present, falls back to
* creating the release with the assets attached on first run.
*
* @param array<int, string> $files
* True when running inside a CI provider. Conservative list — only the
* three providers Pest formally supports / sees in the wild. `CI=true`
* alone is ambiguous (users set it locally too) so we require a
* provider-specific flag.
*/
private function ghReleaseUploadOrCreate(string $repo, array $files): int
private function isCi(): bool
{
$uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber'];
$upload = new Process($uploadArgs);
$upload->setTimeout(300.0);
$upload->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
if ($upload->isSuccessful()) {
return 0;
}
// Release likely doesn't exist yet — create it, attaching the files.
$createArgs = [
'gh', 'release', 'create', self::RELEASE_TAG,
...$files,
'-R', $repo,
'--title', 'Pest TIA baseline',
'--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.',
];
$create = new Process($createArgs);
$create->setTimeout(300.0);
$create->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
return $create->isSuccessful() ? 0 : 1;
return getenv('GITHUB_ACTIONS') === 'true'
|| getenv('GITLAB_CI') === 'true'
|| getenv('CIRCLECI') === 'true';
}
private function confirmPublish(string $repo): bool
private function isLaravel(): bool
{
if (! $this->isTerminal()) {
return false;
}
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
$this->output->writeln(sprintf(
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
$repo,
self::RELEASE_TAG,
));
/**
* Laravel projects need a populated `.env` and a generated `APP_KEY`
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
* fires during `setUp`. Include the standard pre-test dance plus the
* extension set typical Laravel apps rely on.
*/
private function laravelWorkflowYaml(): string
{
return <<<'YAML'
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
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
- run: cp .env.example .env
- run: composer install --no-interaction --prefer-dist
- run: php artisan key:generate
- 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
YAML;
}
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
// Unlike the fetch prompt, this one defaults to *No*. Empty input
// or anything other than an explicit "y"/"yes" cancels.
$line = strtolower(trim($line));
return $line === 'y' || $line === 'yes';
private function genericWorkflowYaml(): string
{
return <<<'YAML'
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
YAML;
}
/**
@ -338,89 +284,26 @@ final class BaselineSync
}
/**
* One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI,
* piped input) returns false so scripted runs never hang waiting for
* input.
* Two-step fetch: find the latest successful run of the baseline
* workflow, then download the named artifact from it. Returns
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
* is missing, or any shell step fails.
*
* @return array{graph: string, coverage: ?string}|null
*/
private function confirm(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln('');
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> no local cache — fetch baseline from <fg=white>%s</>? <fg=gray>[Y/n]</>',
$repo,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
$line = strtolower(trim($line));
return $line === '' || $line === 'y' || $line === 'yes';
}
/**
* Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true
* unless `--no-interaction` is explicitly passed, which would make
* scripted invocations (CI, pipes, subshells) hang at a prompt nobody
* sees. Combining both signals is the safe default.
*/
private function isTerminal(): bool
{
if (! $this->input->isInteractive()) {
return false;
}
if (! defined('STDIN')) {
return false;
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN) === true;
}
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN) === true;
}
return false;
}
/**
* Tries `gh` first (handles private repos + rate limiting via the
* user's GitHub auth), falls through to public HTTPS. Returns the
* raw asset bytes, or null on any failure.
*/
private function download(string $repo, string $asset): ?string
{
$viaGh = $this->downloadViaGh($repo, $asset);
if ($viaGh !== null) {
return $viaGh;
}
return $this->downloadViaHttps($repo, $asset);
}
private function downloadViaGh(string $repo, string $asset): ?string
private function download(string $repo): ?array
{
if (! $this->commandExists('gh')) {
return null;
}
$runId = $this->latestSuccessfulRunId($repo);
if ($runId === null) {
return null;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
@ -428,51 +311,67 @@ final class BaselineSync
}
$process = new Process([
'gh', 'release', 'download', self::RELEASE_TAG,
'gh', 'run', 'download', $runId,
'-R', $repo,
'-p', $asset,
'-n', self::ARTIFACT_NAME,
'-D', $tmpDir,
'--clobber',
]);
$process->setTimeout(120.0);
$process->run();
$payload = null;
if (! $process->isSuccessful()) {
$this->cleanup($tmpDir);
if ($process->isSuccessful()) {
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
if (is_file($path)) {
$content = @file_get_contents($path);
$payload = $content === false ? null : $content;
}
return null;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
if ($graph === false) {
$this->cleanup($tmpDir);
return null;
}
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
$this->cleanup($tmpDir);
return $payload;
return [
'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage,
];
}
private function downloadViaHttps(string $repo, string $asset): ?string
/**
* Queries GitHub for the most recent successful run of the baseline
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
* into an empty string, which we map to null.
*/
private function latestSuccessfulRunId(string $repo): ?string
{
$url = sprintf(
'https://github.com/%s/releases/download/%s/%s',
$repo,
self::RELEASE_TAG,
$asset,
);
$ctx = stream_context_create([
'http' => [
'timeout' => 120,
'follow_location' => 1,
'ignore_errors' => false,
],
$process = new Process([
'gh', 'run', 'list',
'-R', $repo,
'--workflow', self::WORKFLOW_FILE,
'--status', 'success',
'--limit', '1',
'--json', 'databaseId',
'--jq', '.[0].databaseId // empty',
]);
$process->setTimeout(30.0);
$process->run();
$content = @file_get_contents($url, false, $ctx);
if (! $process->isSuccessful()) {
return null;
}
return $content === false ? null : $content;
$runId = trim($process->getOutput());
return $runId === '' ? null : $runId;
}
private function commandExists(string $cmd): bool

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

@ -38,7 +38,7 @@ final readonly class ChangedFiles
* that git still reports as modified but whose content is bit-identical
* to the previous TIA invocation.
*
* @param array<int, string> $files project-relative paths.
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
*/
@ -101,7 +101,7 @@ final readonly class ChangedFiles
* detect which files are actually different.
*
* @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash
* @return array<string, string> path → xxh128 content hash
*/
public function snapshotTree(array $files): array
{

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

@ -46,7 +46,7 @@ final class CoverageMerger
{
$state = self::state();
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
@ -60,7 +60,7 @@ final class CoverageMerger
// verbatim (as serialised bytes) for next time.
$current = self::requireCoverage($reportPath);
if ($current !== null) {
if ($current instanceof CodeCoverage) {
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
}
@ -70,7 +70,7 @@ final class CoverageMerger
$cached = self::unserializeCoverage($cachedBytes);
$current = self::requireCoverage($reportPath);
if ($cached === null || $current === null) {
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
return;
}
@ -84,7 +84,7 @@ final class CoverageMerger
// can `require` it, and to the state cache for the next run.
@file_put_contents(
$reportPath,
"<?php return unserialize(".var_export($serialised, true).");\n",
'<?php return unserialize('.var_export($serialised, true).");\n",
);
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
}
@ -108,10 +108,12 @@ final class CoverageMerger
foreach ($lineCoverage as $file => $lines) {
foreach ($lines as $line => $ids) {
if ($ids === null || $ids === []) {
if ($ids === null) {
continue;
}
if ($ids === []) {
continue;
}
$filtered = array_values(array_diff($ids, $currentIds));
if ($filtered !== $ids) {
@ -175,7 +177,6 @@ final class CoverageMerger
private static function unserializeCoverage(string $bytes): ?CodeCoverage
{
try {
/** @var mixed $value */
$value = @unserialize($bytes);
} catch (Throwable) {
return null;

View File

@ -17,14 +17,14 @@ use Pest\Plugins\Tia\Contracts\State;
*
* @internal
*/
final class FileState implements 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 readonly string $rootDir;
private string $rootDir;
public function __construct(string $rootDir)
{

View File

@ -5,52 +5,161 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Captures environmental inputs that, when changed, make the TIA graph stale.
* Captures environmental inputs that, when changed, may make the TIA graph
* or its recorded results stale. The fingerprint is split into two buckets:
*
* Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change
* what a test actually exercises, so the graph must be rebuilt in those cases.
* - **structural** — describes what the graph's *edges* were recorded
* against. If any of these drift (`composer.lock`, `tests/Pest.php`,
* Pest's factory codegen, etc.) the edges themselves are potentially
* wrong and the graph must rebuild from scratch.
* - **environmental** — describes the *runtime* the results were captured
* on (PHP minor, extension set, Pest version). Drift here means the
* edges are still trustworthy, but the cached per-test results (pass/
* fail/time) may not reproduce on this machine. Tia's handler drops the
* branch's results + coverage cache and re-runs to freshen them, rather
* than re-recording from scratch.
*
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
* rebuilt on first load; the schema bump in the structural bucket takes
* care of that automatically.
*
* @internal
*/
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;
// Bump this whenever the set of inputs or the hash algorithm changes,
// so older graphs are invalidated automatically.
private const int SCHEMA_VERSION = 4;
/**
* @return array<string, int|string|null>
* @return array{
* structural: array<string, int|string|null>,
* environmental: array<string, string|null>,
* }
*/
public static function compute(string $projectRoot): array
{
return [
'schema' => self::SCHEMA_VERSION,
'php' => PHP_VERSION,
'pest' => self::readPestVersion($projectRoot),
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
// Pest's generated classes bake the code-generation logic in — if
// TestCaseFactory changes (new attribute, different method
// signature, etc.) every previously-recorded edge is stale.
// Hashing the factory sources makes path-repo / dev-main installs
// automatically rebuild their graphs when Pest itself is edited.
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'structural' => [
'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
// Pest's generated classes bake the code-generation logic
// in — if TestCaseFactory changes (new attribute, different
// method signature, etc.) every previously-recorded edge is
// stale. Hashing the factory sources makes path-repo /
// dev-main installs automatically rebuild their graphs when
// Pest itself is edited.
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
],
'environmental' => [
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
// almost never matches a dev's Herd/Homebrew install, and
// the patch rarely changes anything test-visible.
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint(),
'pest' => self::readPestVersion($projectRoot),
],
];
}
/**
* True when the structural buckets match. Drift here means the edges
* are potentially wrong; caller should discard the graph and rebuild.
*
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
public static function matches(array $a, array $b): bool
public static function structuralMatches(array $a, array $b): bool
{
ksort($a);
ksort($b);
$aStructural = self::structuralOnly($a);
$bStructural = self::structuralOnly($b);
return $a === $b;
ksort($aStructural);
ksort($bStructural);
return $aStructural === $bStructural;
}
/**
* Returns a list of field names that drifted between the stored and
* current environmental fingerprints. Empty list = no drift. Caller
* uses this to print a human-readable warning and to decide whether
* per-test results should be dropped (any drift → yes).
*
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function environmentalDrift(array $stored, array $current): array
{
$a = self::environmentalOnly($stored);
$b = self::environmentalOnly($current);
$drifts = [];
foreach ($a as $key => $value) {
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function structuralOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'structural');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function environmentalOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'environmental');
}
/**
* Returns `$fingerprint[$key]` as an `array<string, mixed>` if it exists
* and is an array, otherwise empty. Legacy flat-shape fingerprints
* (schema ≤ 3) return empty here, which makes `structuralMatches` fail
* and the caller rebuild — the clean migration path.
*
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function bucket(array $fingerprint, string $key): array
{
$raw = $fingerprint[$key] ?? null;
if (! is_array($raw)) {
return [];
}
$normalised = [];
foreach ($raw as $k => $v) {
if (is_string($k)) {
$normalised[$k] = $v;
}
}
return $normalised;
}
private static function hashIfExists(string $path): ?string
@ -64,6 +173,25 @@ final readonly class Fingerprint
return $hash === false ? null : $hash;
}
/**
* Deterministic hash of the PHP extension set: `ext-name@version` pairs
* sorted alphabetically and joined.
*/
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));
}
private static function readPestVersion(string $projectRoot): string
{
$installed = $projectRoot.'/vendor/composer/installed.json';

View File

@ -223,7 +223,7 @@ final class Graph
}
/**
* @param array<string, int|string|null> $fingerprint
* @param array<string, mixed> $fingerprint
*/
public function setFingerprint(array $fingerprint): void
{
@ -231,7 +231,7 @@ final class Graph
}
/**
* @return array<string, int|string|null>
* @return array<string, mixed>
*/
public function fingerprint(): array
{
@ -282,9 +282,7 @@ final class Graph
return null;
}
$value = $baseline['results'][$testId]['assertions'];
return is_int($value) ? $value : null;
return $baseline['results'][$testId]['assertions'];
}
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
@ -323,6 +321,20 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree;
}
/**
* Wipes cached per-test results for the given branch. Edges and tree
* snapshot stay intact — the graph still describes the code correctly,
* only the "what happened last time" data is reset. Used on
* environmental fingerprint drift: the edges were recorded elsewhere
* (e.g. CI) so they're still valid, but the results aren't trustworthy
* on this machine until the tests re-run here.
*/
public function clearResults(string $branch): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['results'] = [];
}
/**
* @return array<string, string>
*/

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],

View File

@ -16,9 +16,9 @@ use PHPUnit\Event\Test\FinishedSubscriber;
*
* @internal
*/
final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(Finished $event): void
{

View File

@ -11,9 +11,9 @@ use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
final readonly class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(Errored $event): void
{

View File

@ -11,9 +11,9 @@ use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
final readonly class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(Failed $event): void
{

View File

@ -11,9 +11,9 @@ use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
final readonly class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(MarkedIncomplete $event): void
{

View File

@ -11,9 +11,9 @@ use PHPUnit\Event\Test\PassedSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
final readonly class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(Passed $event): void
{

View File

@ -11,9 +11,9 @@ use PHPUnit\Event\Test\ConsideredRiskySubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
final readonly class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(ConsideredRisky $event): void
{

View File

@ -11,9 +11,9 @@ use PHPUnit\Event\Test\SkippedSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
final readonly class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(Skipped $event): void
{

View File

@ -20,9 +20,9 @@ use PHPUnit\Event\Test\PreparedSubscriber;
*
* @internal
*/
final class EnsureTiaResultsAreCollected implements PreparedSubscriber
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function __construct(private ResultCollector $collector) {}
public function notify(Prepared $event): void
{

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Support;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
@ -92,7 +93,7 @@ final class Coverage
// tests. Merge their fresh coverage slice into the cached full-run
// snapshot (stored by the previous `--tia --coverage` pass) so the
// report reflects the entire suite, not just what re-ran.
\Pest\Plugins\Tia\CoverageMerger::applyIfMarked($reportPath);
CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;