This commit is contained in:
nuno maduro
2026-04-20 13:48:05 -07:00
parent 55a3394f8c
commit 59e781e77b
6 changed files with 383 additions and 247 deletions

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* Storage contract for TIA's persistent state (graph, baselines, affected
* set, worker partials, coverage snapshots). Modelled as a flat key/value
* store of raw byte blobs so implementations can sit on top of whatever
* backend fits — a directory, a shared cache, a remote object store — and
* TIA's logic stays identical.
*
* @internal
*/
interface State
{
/**
* Returns the stored blob for `$key`, or `null` when the key is unset
* or cannot be read.
*/
public function read(string $key): ?string;
/**
* Atomically stores `$content` under `$key`. Existing value (if any) is
* replaced. Implementations SHOULD guarantee that concurrent readers
* never observe partial writes.
*/
public function write(string $key, string $content): bool;
/**
* Removes `$key`. Returns true whether or not the key existed beforehand
* — callers should treat a `true` result as "the key is now absent",
* not "the key was present and has been removed."
*/
public function delete(string $key): bool;
public function exists(string $key): bool;
/**
* Returns every key whose name starts with `$prefix`. Used to collect
* paratest worker partials (`tia-worker-<token>.json`, etc.) without
* exposing backend-specific glob semantics.
*
* @return list<string>
*/
public function keysWithPrefix(string $prefix): array;
}

View File

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable;
@ -14,7 +16,7 @@ use Throwable;
* executing only the affected tests.
*
* Invoked from `Pest\Support\Coverage::report()` right before the coverage
* file is consumed. A marker file dropped by the `Tia` plugin gates the
* file is consumed. A marker dropped by the `Tia` plugin gates the
* behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
* and therefore keep their existing semantics.
*
@ -24,19 +26,17 @@ use Throwable;
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the
* list of test IDs that covered that line. We:
*
* 1. Load the cached snapshot (from a previous `--tia --coverage` run).
* 1. Load the cached snapshot from `State` (serialised bytes).
* 2. Strip every test id that re-ran this time from the cached map —
* the tests that ran now are the ones whose attribution is fresh.
* 3. Merge the current run into the stripped cached snapshot via
* `CodeCoverage::merge()`.
* 4. Write the merged result back to the report path (so Pest's report
* generator sees the full suite) and to the cache path (for the
* generator sees the full suite) and back into `State` (for the
* next invocation).
* 5. Remove the marker so subsequent plain `--coverage` runs are
* untouched.
*
* If no cache exists yet (first `--tia --coverage` run on this machine)
* we simply save the current file as the cache — nothing to merge yet.
* we serialise the current object and save it — nothing to merge yet.
*
* @internal
*/
@ -44,35 +44,33 @@ final class CoverageMerger
{
public static function applyIfMarked(string $reportPath): void
{
$markerPath = Tia::coverageMarkerPath();
$state = self::state();
if (! is_file($markerPath)) {
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
@unlink($markerPath);
$state->delete(Tia::KEY_COVERAGE_MARKER);
$cachePath = Tia::coverageCachePath();
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if (! is_file($cachePath)) {
// First `--tia --coverage` run: nothing cached yet, the current
// report is the full suite itself. Save it verbatim so the next
// run has a snapshot to merge against.
@copy($reportPath, $cachePath);
if ($cachedBytes === null) {
// First `--tia --coverage` run: nothing cached yet, so the
// current file already represents the full suite. Capture it
// verbatim (as serialised bytes) for next time.
$current = self::requireCoverage($reportPath);
if ($current !== null) {
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
}
return;
}
try {
/** @var CodeCoverage $cached */
$cached = require $cachePath;
$cached = self::unserializeCoverage($cachedBytes);
$current = self::requireCoverage($reportPath);
/** @var CodeCoverage $current */
$current = require $reportPath;
} catch (Throwable) {
// Corrupt cache or unreadable report — fall back to the plain
// PHPUnit behaviour (the existing `require $reportPath` in the
// caller still runs against the untouched file).
if ($cached === null || $current === null) {
return;
}
@ -80,15 +78,15 @@ final class CoverageMerger
$cached->merge($current);
// Serialise the merged object back using PHPUnit's own "return
// expression" PHP format. Using `var_export` on the serialised
// payload keeps the file self-contained and independent of
// PHPUnit's internal exporter — the reader only needs to
// `require` it back.
$serialised = "<?php return unserialize(".var_export(serialize($cached), true).");\n";
$serialised = serialize($cached);
@file_put_contents($reportPath, $serialised);
@file_put_contents($cachePath, $serialised);
// Write back to the PHPUnit-style `.cov` path so the report reader
// can `require` it, and to the state cache for the next run.
@file_put_contents(
$reportPath,
"<?php return unserialize(".var_export($serialised, true).");\n",
);
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
}
/**
@ -146,4 +144,43 @@ final class CoverageMerger
return array_keys($ids);
}
private static function state(): ?State
{
try {
$state = Container::getInstance()->get(State::class);
} catch (Throwable) {
return null;
}
return $state instanceof State ? $state : null;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage
{
if (! is_file($reportPath)) {
return null;
}
try {
/** @var mixed $value */
$value = require $reportPath;
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
private static function unserializeCoverage(string $bytes): ?CodeCoverage
{
try {
/** @var mixed $value */
$value = @unserialize($bytes);
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
}

View File

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
/**
* Filesystem-backed implementation of the TIA `State` contract. Each key
* maps verbatim to a file name under `$rootDir`, so existing `.temp/*.json`
* layouts are preserved exactly.
*
* The root directory is created lazily on first write — callers don't have
* to pre-provision it, and reads against a missing directory simply return
* `null` rather than throwing.
*
* @internal
*/
final 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;
public function __construct(string $rootDir)
{
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
}
public function read(string $key): ?string
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return null;
}
$bytes = @file_get_contents($path);
return $bytes === false ? null : $bytes;
}
public function write(string $key, string $content): bool
{
if (! $this->ensureRoot()) {
return false;
}
$path = $this->pathFor($key);
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $content) === false) {
return false;
}
// Atomic rename — on POSIX filesystems this is a single-step
// replacement, so concurrent readers never see a half-written file.
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
public function delete(string $key): bool
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return true;
}
return @unlink($path);
}
public function exists(string $key): bool
{
return is_file($this->pathFor($key));
}
public function keysWithPrefix(string $prefix): array
{
$root = $this->resolvedRoot();
if ($root === null) {
return [];
}
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
$matches = glob($pattern);
if ($matches === false) {
return [];
}
$keys = [];
foreach ($matches as $path) {
$keys[] = basename($path);
}
return $keys;
}
/**
* Absolute path for `$key`. Not part of the interface — used by the
* coverage merger and similar callers that need direct filesystem
* access (e.g. `require` on a cached PHP file). Consumers that only
* deal in bytes should go through `read()` / `write()`.
*/
public function pathFor(string $key): string
{
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
}
/**
* Returns the resolved root if it exists already, otherwise `null`.
* Used by read-side helpers so they don't eagerly create the directory
* just to find nothing inside.
*/
private function resolvedRoot(): ?string
{
$resolved = @realpath($this->rootDir);
return $resolved === false ? null : $resolved;
}
/**
* Creates the root dir on demand. Returns false only when creation
* fails and the directory still isn't there afterwards.
*/
private function ensureRoot(): bool
{
if (is_dir($this->rootDir)) {
return true;
}
if (@mkdir($this->rootDir, 0755, true)) {
return true;
}
return is_dir($this->rootDir);
}
}

View File

@ -374,19 +374,15 @@ final class Graph
}
}
public static function load(string $projectRoot, string $path): ?self
/**
* Rebuilds a graph from its JSON representation. Returns `null` when
* the payload is missing, unreadable, or schema-incompatible. Separated
* from transport (state backend, file, etc.) so tests can feed bytes
* directly without touching disk.
*/
public static function decode(string $json, string $projectRoot): ?self
{
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
$data = json_decode($json, true);
if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
return null;
@ -402,14 +398,14 @@ final class Graph
return $graph;
}
public function save(string $path): bool
/**
* Serialises the graph to its JSON on-disk form. Returns `null` if the
* payload can't be encoded (extremely rare — pathological UTF-8 only).
* Persistence is the caller's responsibility: write the returned bytes
* through whatever `State` implementation is in play.
*/
public function encode(): ?string
{
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return false;
}
$payload = [
'schema' => 1,
'fingerprint' => $this->fingerprint,
@ -418,25 +414,9 @@ final class Graph
'baselines' => $this->baselines,
];
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($json === false) {
return false;
}
if (@file_put_contents($tmp, $json) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
return $json === false ? null : $json;
}
/**