From 59e781e77b6995c7cab1bb3cc9b753f7dd75db62 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 13:48:05 -0700 Subject: [PATCH] wip --- src/Kernel.php | 3 +- src/Plugins/Tia.php | 276 ++++++++++------------------ src/Plugins/Tia/Contracts/State.php | 48 +++++ src/Plugins/Tia/CoverageMerger.php | 101 ++++++---- src/Plugins/Tia/FileState.php | 150 +++++++++++++++ src/Plugins/Tia/Graph.php | 52 ++---- 6 files changed, 383 insertions(+), 247 deletions(-) create mode 100644 src/Plugins/Tia/Contracts/State.php create mode 100644 src/Plugins/Tia/FileState.php diff --git a/src/Kernel.php b/src/Kernel.php index bf57f51e..e0ff28c8 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -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, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 3503dd1b..2f9990ff 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -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(' TIA failed to write graph to '.$cachePath); + if (! $this->saveGraph($graph)) { + $this->output->writeln(' TIA failed to write graph.'); $recorder->reset(); return; } $this->output->writeln(sprintf( - ' TIA graph recorded (%d test files) at %s', + ' 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(' TIA failed to write graph to '.$cachePath); + if (! $this->saveGraph($graph)) { + $this->output->writeln(' TIA failed to write graph.'); return $exitCode; } $this->output->writeln(sprintf( - ' TIA graph recorded (%d test files, %d worker partials) at %s', + ' 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 + * @return list 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 + * @return list 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>|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(); } diff --git a/src/Plugins/Tia/Contracts/State.php b/src/Plugins/Tia/Contracts/State.php new file mode 100644 index 00000000..b109e3ec --- /dev/null +++ b/src/Plugins/Tia/Contracts/State.php @@ -0,0 +1,48 @@ +.json`, etc.) without + * exposing backend-specific glob semantics. + * + * @return list + */ + public function keysWithPrefix(string $prefix): array; +} diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php index 3094c8bc..fb482023 100644 --- a/src/Plugins/Tia/CoverageMerger.php +++ b/src/Plugins/Tia/CoverageMerger.php @@ -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 = "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; + } } diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php new file mode 100644 index 00000000..33060d8e --- /dev/null +++ b/src/Plugins/Tia/FileState.php @@ -0,0 +1,150 @@ +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); + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 24643bb3..5f4efc0a 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -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; } /**