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

@ -69,7 +69,8 @@ final readonly class Kernel
->add(Tia\Recorder::class, new Tia\Recorder)
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
->add(Tia\ResultCollector::class, new Tia\ResultCollector)
->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp'));
$kernel = new self(
new Application,

View File

@ -9,6 +9,7 @@ use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use PHPUnit\Framework\TestStatus\TestStatus;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageCollector;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
@ -72,37 +73,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REBUILD_OPTION = '--tia-rebuild';
/**
* TIA cache lives inside Pest's `.temp/` directory (same location as
* PHPUnit's result cache). This directory is gitignored by default in
* Pest's own `.gitignore`, so the graph is never committed.
* 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.
*/
private const string TEMP_DIR = __DIR__
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'.temp';
public const string KEY_GRAPH = 'tia.json';
private const string CACHE_FILE = 'tia.json';
public const string KEY_AFFECTED = 'tia-affected.json';
private const string AFFECTED_FILE = 'tia-affected.json';
private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-';
private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-';
/**
* Cache file holding PHPUnit's `CodeCoverage` object from the last
* `--tia --coverage` run. When the next run replays most tests from
* the TIA graph, only the affected tests produce fresh coverage; the
* rest is merged in from this cache so the report stays complete.
* 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.
*/
private const string COVERAGE_CACHE_FILE = 'tia-coverage.php';
public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin';
/**
* Marker file 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
* current (narrow) behaviour.
*/
private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker';
private const string WORKER_PREFIX = 'tia-worker-';
private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-';
public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker';
/**
* Global flag toggled by the parent process so workers know to record.
@ -168,57 +163,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private array $affectedFiles = [];
private static function tempDir(): string
private static function workerEdgesKey(string $token): string
{
$dir = (string) realpath(self::TEMP_DIR);
if ($dir === '' || $dir === '.') {
// .temp doesn't exist yet — create it.
@mkdir(self::TEMP_DIR, 0755, true);
$dir = (string) realpath(self::TEMP_DIR);
}
return $dir;
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
}
private static function cachePath(): string
private static function workerResultsKey(string $token): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function affectedPath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE;
}
private static function workerPath(string $token): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json';
}
private static function workerGlob(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
}
private static function workerResultsPath(string $token): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.$token.'.json';
}
private static function workerResultsGlob(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json';
}
public static function coverageCachePath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_CACHE_FILE;
}
public static function coverageMarkerPath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_MARKER_FILE;
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
}
/**
@ -242,8 +194,37 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private readonly Recorder $recorder,
private readonly CoverageCollector $coverageCollector,
private readonly WatchPatterns $watchPatterns,
private readonly State $state,
) {}
/**
* Convenience wrapper: load + decode the graph, or return `null` if no
* graph has been stored. Any call that needs to mutate + re-save the
* graph also goes through `saveGraph()` to keep bytes flowing through
* the `State` abstraction rather than filesystem paths.
*/
private function loadGraph(string $projectRoot): ?Graph
{
$json = $this->state->read(self::KEY_GRAPH);
if ($json === null) {
return null;
}
return Graph::decode($json, $projectRoot);
}
private function saveGraph(Graph $graph): bool
{
$json = $graph->encode();
if ($json === null) {
return false;
}
return $this->state->write(self::KEY_GRAPH, $json);
}
/**
* Returns the cached result for the given test, or `null` if the test
* must run (affected, unknown, or no replay mode active).
@ -367,12 +348,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
// Non-parallel record path: straight into the main cache.
$cachePath = self::cachePath();
$changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha();
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot whatever is currently dirty in the working tree. Without
@ -386,17 +365,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceEdges($perTest);
$graph->pruneMissingTests();
if (! $graph->save($cachePath)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
$recorder->reset();
return;
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files) at %s',
' <fg=green>TIA</> graph recorded (%d test files).',
count($perTest),
self::CACHE_FILE,
));
$recorder->reset();
@ -443,18 +421,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
$projectRoot = TestSuite::getInstance()->rootPath;
$partials = $this->collectWorkerPartials($projectRoot);
$partialKeys = $this->collectWorkerEdgesPartials();
if ($partials === []) {
if ($partialKeys === []) {
return $exitCode;
}
$cachePath = self::cachePath();
$changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha();
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot any currently-dirty files so the first replay run
@ -466,8 +442,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$merged = [];
foreach ($partials as $partialPath) {
$data = $this->readPartial($partialPath);
foreach ($partialKeys as $key) {
$data = $this->readPartial($key);
if ($data === null) {
continue;
@ -483,7 +459,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
}
@unlink($partialPath);
$this->state->delete($key);
}
$finalised = [];
@ -495,17 +471,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceEdges($finalised);
$graph->pruneMissingTests();
if (! $graph->save($cachePath)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
return $exitCode;
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s',
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials).',
count($finalised),
count($partials),
self::CACHE_FILE,
count($partialKeys),
));
// Persist per-test results (merged from worker partials above) into
@ -533,10 +508,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// the implicit branch identity.
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$cachePath = self::cachePath();
$fingerprint = Fingerprint::compute($projectRoot);
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
$this->output->writeln(
@ -563,7 +537,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// current (narrow) coverage with the cached full-run snapshot. Plain
// `--coverage` runs don't drop it, so their behaviour is untouched.
if ($this->piggybackCoverage) {
@file_put_contents(self::coverageMarkerPath(), '');
$this->state->write(self::KEY_COVERAGE_MARKER, '');
}
// First `--tia --coverage` run has nothing to merge against: if we
// replay, the coverage driver sees only the affected tests and the
// 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);
}
if ($graph instanceof Graph) {
@ -628,18 +610,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function installWorkerReplay(string $projectRoot): void
{
$cachePath = self::cachePath();
$affectedPath = self::affectedPath();
$graph = Graph::load($projectRoot, $cachePath);
$graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) {
return;
}
$raw = @file_get_contents($affectedPath);
$raw = $this->state->read(self::KEY_AFFECTED);
if ($raw === false) {
if ($raw === null) {
return;
}
@ -724,32 +703,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function persistAffectedSet(string $projectRoot, array $affected): bool
{
$path = self::affectedPath();
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return false;
}
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
if ($json === false) {
return false;
}
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $json) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
return $this->state->write(self::KEY_AFFECTED, $json);
}
/**
@ -838,49 +798,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function flushWorkerPartial(string $projectRoot, array $perTest): void
{
$path = self::workerPath($this->workerToken());
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
if ($json === false) {
return;
}
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $json) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
$this->state->write(self::workerEdgesKey($this->workerToken()), $json);
}
/**
* @return array<int, string>
* @return list<string> State keys of per-worker edges partials.
*/
private function collectWorkerPartials(string $projectRoot): array
private function collectWorkerEdgesPartials(): array
{
$pattern = self::workerGlob();
$matches = glob($pattern);
return $matches === false ? [] : $matches;
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
}
private function purgeWorkerPartials(string $projectRoot): void
{
foreach ($this->collectWorkerPartials($projectRoot) as $path) {
@unlink($path);
foreach ($this->collectWorkerEdgesPartials() as $key) {
$this->state->delete($key);
}
foreach ($this->collectWorkerReplayPartials() as $path) {
@unlink($path);
foreach ($this->collectWorkerReplayPartials() as $key) {
$this->state->delete($key);
}
}
@ -900,14 +842,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$token = $this->workerToken();
$path = self::workerResultsPath($token);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$json = json_encode([
'results' => $results,
'replayed' => $this->replayedCount,
@ -917,25 +851,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $json) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
$this->state->write(self::workerResultsKey($this->workerToken()), $json);
}
/**
* @return array<int, string>
* @return list<string> State keys of per-worker replay partials.
*/
private function collectWorkerReplayPartials(): array
{
$matches = glob(self::workerResultsGlob());
return $matches === false ? [] : $matches;
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
}
/**
@ -948,17 +872,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
foreach ($this->collectWorkerReplayPartials() as $path) {
$raw = @file_get_contents($path);
if ($raw === false) {
@unlink($path);
foreach ($this->collectWorkerReplayPartials() as $key) {
$raw = $this->state->read($key);
$this->state->delete($key);
if ($raw === null) {
continue;
}
$decoded = json_decode($raw, true);
@unlink($path);
if (! is_array($decoded)) {
continue;
@ -1009,11 +931,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/**
* @return array<string, array<int, string>>|null
*/
private function readPartial(string $path): ?array
private function readPartial(string $key): ?array
{
$raw = @file_get_contents($path);
$raw = $this->state->read($key);
if ($raw === false) {
if ($raw === null) {
return null;
}
@ -1079,9 +1001,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function bumpRecordedSha(): void
{
$projectRoot = TestSuite::getInstance()->rootPath;
$cachePath = self::cachePath();
$graph = Graph::load($projectRoot, $cachePath);
$graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) {
return;
@ -1100,7 +1021,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
$graph->save($cachePath);
$this->saveGraph($graph);
}
/**
@ -1119,10 +1040,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$cachePath = self::cachePath();
$projectRoot = TestSuite::getInstance()->rootPath;
$graph = Graph::load($projectRoot, $cachePath);
$graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) {
return;
@ -1132,7 +1052,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']);
}
$graph->save($cachePath);
$this->saveGraph($graph);
$collector->reset();
}

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;
}
/**