From d0295f6168d38e6a49e5b3b130a3d0e969089898 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 1 May 2026 23:59:25 +0100 Subject: [PATCH] wip --- src/Exceptions/BaselineFetchFailed.php | 23 +++- src/Plugins/Tia.php | 98 ++++++++------ src/Plugins/Tia/BaselineSync.php | 27 ++-- src/Plugins/Tia/Graph.php | 17 +++ src/Plugins/Tia/TestPaths.php | 170 +++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 56 deletions(-) create mode 100644 src/Plugins/Tia/TestPaths.php diff --git a/src/Exceptions/BaselineFetchFailed.php b/src/Exceptions/BaselineFetchFailed.php index eeb3e095..301345c2 100644 --- a/src/Exceptions/BaselineFetchFailed.php +++ b/src/Exceptions/BaselineFetchFailed.php @@ -20,6 +20,7 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt public function __construct( private readonly string $headline, private readonly string $hint, + private readonly bool $hasAnchor = false, ) { parent::__construct($headline); } @@ -28,16 +29,26 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt { View::renderUsing($output); - View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]); - View::render('components.two-column-detail', ['left' => $this->hint, 'right' => '']); - View::render('components.two-column-detail', [ - 'left' => 'Bypass with --fresh to record locally and skip the baseline fetch.', - 'right' => '', - ]); + if (! $this->hasAnchor) { + View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]); + $this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.'); + $output->writeln(''); + + return; + } + + $this->renderChild($output, $this->headline); + $this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.'); + $output->writeln(''); } public function exitCode(): int { return 1; } + + private function renderChild(OutputInterface $output, string $text): void + { + $output->writeln(sprintf(' ─ %s', $text)); + } } diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index b952ffff..9855b034 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -90,6 +90,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable /** @var array */ private array $affectedFiles = []; + /** @var array{structural: array, environmental: array}|null */ + private ?array $startFingerprint = null; + private function workerEdgesKey(string $token): string { return self::KEY_WORKER_EDGES_PREFIX.$token.'.json'; @@ -126,9 +129,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable View::render('components.badge', ['type' => $type, 'content' => $content]); } - private function renderDetail(string $left, string $right = ''): void + private function renderChild(string $text): void { - View::render('components.two-column-detail', ['left' => $left, 'right' => $right]); + $this->output->writeln(sprintf(' ─ %s', $text)); + } + + /** + * @param array{structural: array, environmental: array} $current + */ + private function structuralFingerprintShifted(array $current): bool + { + assert($this->startFingerprint !== null); + + return ! Fingerprint::structuralMatches($this->startFingerprint, $current); } private function loadGraph(string $projectRoot): ?Graph @@ -318,8 +331,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $changedFiles = new ChangedFiles($projectRoot); $currentSha = $changedFiles->currentSha(); + $currentFingerprint = Fingerprint::compute($projectRoot); + + if ($this->structuralFingerprintShifted($currentFingerprint)) { + $this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.'); + $this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.'); + $recorder->reset(); + $this->coverageCollector->reset(); + + return; + } + $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); - $graph->setFingerprint(Fingerprint::compute($projectRoot)); + $graph->setFingerprint($currentFingerprint); $graph->setRecordedAtSha($this->branch, $currentSha); $graph->setLastRunTree( $this->branch, @@ -343,12 +367,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return; } - $this->renderBadge('INFO', sprintf( - 'Recorded the dependency graph (%d test file%s).', - count($perTest), - count($perTest) === 1 ? '' : 's', - )); - $recorder->reset(); $this->coverageCollector->reset(); } @@ -389,8 +407,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $changedFiles = new ChangedFiles($projectRoot); $currentSha = $changedFiles->currentSha(); + $currentFingerprint = Fingerprint::compute($projectRoot); + + if ($this->structuralFingerprintShifted($currentFingerprint)) { + $this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.'); + $this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.'); + + foreach ($partialKeys as $key) { + $this->state->delete($key); + } + + return $exitCode; + } + $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); - $graph->setFingerprint(Fingerprint::compute($projectRoot)); + $graph->setFingerprint($currentFingerprint); $graph->setRecordedAtSha($this->branch, $currentSha); $graph->setLastRunTree( $this->branch, @@ -467,7 +498,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } $this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.'); - $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.'); + $this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.'); return $exitCode; } @@ -487,14 +518,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $exitCode; } - $this->renderBadge('INFO', sprintf( - 'Recorded the dependency graph (%d test file%s, %d worker partial%s).', - count($finalised), - count($finalised) === 1 ? '' : 's', - count($partialKeys), - count($partialKeys) === 1 ? '' : 's', - )); - $this->snapshotTestResults(); return $exitCode; @@ -523,7 +546,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $branchSha, ); if ($summary !== '') { - $this->renderDetail($summary); + $this->renderChild($summary); } } } @@ -567,12 +590,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; $fingerprint = Fingerprint::compute($projectRoot); + $this->startFingerprint = $fingerprint; if ($forceRebuild) { Storage::purge($projectRoot); } - $graph = $forceRebuild ? null : $this->loadGraph($projectRoot); + $graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot); if ($graph instanceof Graph) { $graph = $this->reconcileFingerprint($graph, $fingerprint); @@ -759,8 +783,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if ($hasProjectPhpSourceChanges && ! $coverageAvailable) { $this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.'); - $this->renderDetail('Running the full suite to avoid using a stale dependency graph.'); - $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.'); + $this->renderChild('Running the full suite to avoid using a stale dependency graph.'); + $this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.'); return $arguments; } @@ -836,6 +860,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void { + $this->output->writeln(''); + $this->renderChild('TIA mode enabled.'); + if ($affected === []) { return; } @@ -936,11 +963,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1'); } - $this->renderBadge('INFO', $this->piggybackCoverage - ? 'Recording dependency graph in parallel via --coverage (first run) — '. - 'subsequent --tia runs will only re-execute affected tests.' - : 'Recording dependency graph in parallel (first run) — '. - 'subsequent --tia runs will only re-execute affected tests.'); + $this->output->writeln(''); + $this->renderChild('TIA mode enabled / fresh graph.'); return $arguments; } @@ -948,8 +972,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if ($this->piggybackCoverage) { $this->recordingActive = true; - $this->renderBadge('INFO', 'Recording dependency graph via --coverage (first run) — '. - 'subsequent --tia runs will only re-execute affected tests.'); + $this->output->writeln(''); + $this->renderChild('TIA mode enabled / fresh graph.'); return $arguments; } @@ -957,11 +981,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $recorder->activate(); $this->recordingActive = true; - $this->renderBadge('INFO', sprintf( - 'Recording dependency graph via %s (first run) — '. - 'subsequent --tia runs will only re-execute affected tests.', - $recorder->driver(), - )); + $this->renderChild('Running in TIA mode.'); return $arguments; } @@ -969,8 +989,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private function emitCoverageDriverMissing(): void { $this->renderBadge('WARN', 'No coverage driver is available — skipped.'); - $this->renderDetail('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.'); - $this->renderDetail('Install or enable one and rerun with --tia.'); + $this->renderChild('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.'); + $this->renderChild('Install or enable one and rerun with --tia.'); } /** @@ -1017,7 +1037,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable '%d worker(s) had no coverage driver — their per-test edges and results were dropped.', count($keys), )); - $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.'); + $this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.'); } private function purgeWorkerPartials(): void @@ -1404,7 +1424,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $projectRoot = TestSuite::getInstance()->rootPath; $this->baselineFetchAttemptedForDrift = true; - if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) { + if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) { return null; } diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 8afa4e06..083687a5 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -42,12 +42,12 @@ final readonly class BaselineSync View::render('components.badge', ['type' => $type, 'content' => $content]); } - private function renderDetail(string $left, string $right = ''): void + private function renderChild(string $text): void { - View::render('components.two-column-detail', ['left' => $left, 'right' => $right]); + $this->output->writeln(sprintf(' ─ %s', $text)); } - public function fetchIfAvailable(string $projectRoot, bool $force = false): bool + public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool { $repo = $this->detectGitHubRepo($projectRoot); @@ -65,7 +65,7 @@ final readonly class BaselineSync } $failureKind = null; - $payload = $this->download($repo, $projectRoot, $failureKind); + $payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor); if ($payload === null) { if ($failureKind === 'no-runs' || $failureKind === null) { @@ -151,8 +151,8 @@ final readonly class BaselineSync : $this->genericWorkflowYaml(); $this->renderBadge('WARN', 'No baseline published yet — recording locally.'); - $this->renderDetail('To share the baseline with your team, add this workflow to the repo:'); - $this->renderDetail('.github/workflows/tia-baseline.yml'); + $this->renderChild('To share the baseline with your team, add this workflow to the repo:'); + $this->renderChild('.github/workflows/tia-baseline.yml'); $indentedYaml = array_map( static fn (string $line): string => ' '.$line, @@ -161,8 +161,8 @@ final readonly class BaselineSync $this->output->writeln(['', ...$indentedYaml, '']); - $this->renderDetail(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo)); - $this->renderDetail('Details: https://pestphp.com/docs/tia/ci'); + $this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo)); + $this->renderChild('Details: https://pestphp.com/docs/tia/ci'); } private function isCi(): bool @@ -285,7 +285,7 @@ YAML; * * @return array{graph: string, coverage: ?string}|null */ - private function download(string $repo, string $projectRoot, ?string &$failureKind = null): ?array + private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array { $failureKind = null; @@ -293,6 +293,7 @@ YAML; Panic::with(new BaselineFetchFailed( 'GitHub CLI (gh) not found — cannot fetch baseline.', 'Install it from https://cli.github.com.', + $hasAnchor, )); } @@ -300,6 +301,7 @@ YAML; Panic::with(new BaselineFetchFailed( 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', 'Run `gh auth login` and retry.', + $hasAnchor, )); } @@ -311,7 +313,8 @@ YAML; if (in_array($failureKind, ['forbidden', 'not-found'], true)) { Panic::with(new BaselineFetchFailed( sprintf('Failed to query baseline runs — %s', $listError['message']), - 'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.', + 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.', + $hasAnchor, )); } @@ -388,7 +391,8 @@ YAML; if (in_array($failureKind, ['forbidden', 'not-found'], true)) { Panic::with(new BaselineFetchFailed( sprintf('Baseline download failed — %s', $diagnosis['message']), - 'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.', + 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.', + $hasAnchor, )); } @@ -408,6 +412,7 @@ YAML; Panic::with(new BaselineFetchFailed( 'Baseline downloaded but the artifact is missing expected files (graph.json).', 'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.', + $hasAnchor, )); } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 796ef4a6..0010b8d9 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -316,6 +316,23 @@ final class Graph } } + // A changed file inside the configured test suites is itself the unit + // of work — always run it (new untracked tests, edited tests, renames). + $testPaths = TestPaths::fromProjectRoot($this->projectRoot); + + foreach ($nonMigrationPaths as $rel) { + if (isset($affectedSet[$rel])) { + continue; + } + if (! $testPaths->isTestFile($rel)) { + continue; + } + if (! is_file($this->projectRoot.'/'.$rel)) { + continue; + } + $affectedSet[$rel] = true; + } + // Unknown Blade files: walk static references (@include, @extends, ) up to rendered $staticallyHandledBlade = []; foreach ($nonMigrationPaths as $rel) { diff --git a/src/Plugins/Tia/TestPaths.php b/src/Plugins/Tia/TestPaths.php new file mode 100644 index 00000000..7610b6a7 --- /dev/null +++ b/src/Plugins/Tia/TestPaths.php @@ -0,0 +1,170 @@ +. Falls back to the runtime TestSuite + * configuration when no config file is present. + * + * @internal + */ +final readonly class TestPaths +{ + /** + * @param list $directories Project-relative directory prefixes (no trailing slash). + * @param list $files Project-relative file paths. + * @param list $suffixes Filename suffixes (e.g. '.php'). + */ + public function __construct( + private array $directories, + private array $files, + private array $suffixes, + ) {} + + public static function fromProjectRoot(string $projectRoot): self + { + $configPath = self::configPath($projectRoot); + + $directories = []; + $files = []; + $suffixes = ['.php']; + + if ($configPath !== null) { + $xml = @simplexml_load_file($configPath); + + if ($xml !== false) { + $configDir = dirname($configPath); + + foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) { + $rel = self::toRelative((string) $node, $configDir, $projectRoot); + + if ($rel !== null) { + $directories[] = $rel; + } + + $suffix = (string) ($node['suffix'] ?? ''); + if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) { + $suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix; + } + } + + foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) { + $rel = self::toRelative((string) $node, $configDir, $projectRoot); + + if ($rel !== null) { + $files[] = $rel; + } + } + } + } + + if ($directories === [] && $files === []) { + $fallback = self::testSuiteFallback($projectRoot); + + if ($fallback !== null) { + $directories[] = $fallback; + } + } + + return new self( + array_values(array_unique($directories)), + array_values(array_unique($files)), + array_values(array_unique($suffixes)), + ); + } + + public function isTestFile(string $relativePath): bool + { + if (in_array($relativePath, $this->files, true)) { + return true; + } + + $matchesSuffix = false; + foreach ($this->suffixes as $suffix) { + if (str_ends_with($relativePath, $suffix)) { + $matchesSuffix = true; + + break; + } + } + + if (! $matchesSuffix) { + return false; + } + + foreach ($this->directories as $dir) { + if ($dir === '') { + continue; + } + if (str_starts_with($relativePath, $dir.'/')) { + return true; + } + } + + return false; + } + + private static function configPath(string $projectRoot): ?string + { + foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) { + $candidate = $projectRoot.DIRECTORY_SEPARATOR.$name; + + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + private static function toRelative(string $value, string $configDir, string $projectRoot): ?string + { + $value = trim($value); + + if ($value === '') { + return null; + } + + $isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR + || (strlen($value) >= 2 && $value[1] === ':'); + + $combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value; + + $real = @realpath($combined); + $resolved = $real === false ? $combined : $real; + + $resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved); + $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/'; + + if (! str_starts_with($resolved.'/', $root)) { + return null; + } + + return rtrim(substr($resolved, strlen($root)), '/'); + } + + private static function testSuiteFallback(string $projectRoot): ?string + { + try { + $testPath = TestSuite::getInstance()->testPath; + } catch (\Throwable) { + return null; + } + + $real = @realpath($testPath); + $resolved = $real === false ? $testPath : $real; + $resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved); + $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/'; + + if (! str_starts_with($resolved.'/', $root)) { + return null; + } + + return rtrim(substr($resolved, strlen($root)), '/'); + } +}