From 89f3d6cb391422c83f066825e993330d8e9f3b40 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Sat, 2 May 2026 17:45:54 +0100 Subject: [PATCH] wip --- src/Plugins/Tia.php | 99 ++++---- src/Plugins/Tia/BaselineSync.php | 101 ++++++--- src/Plugins/Tia/Fingerprint.php | 49 ++-- src/Plugins/Tia/Graph.php | 373 +++++++++++++++++++------------ src/Plugins/Tia/SourceScope.php | 1 - 5 files changed, 358 insertions(+), 265 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 4c92911a..799578ef 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -479,67 +479,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), ); - $mergedFiles = []; - $mergedTables = []; - $mergedInertia = []; - - foreach ($partialKeys as $key) { - $data = $this->readPartial($key); - - if ($data === null) { - continue; - } - - foreach ($data['files'] as $testFile => $sources) { - if (! isset($mergedFiles[$testFile])) { - $mergedFiles[$testFile] = []; - } - - foreach ($sources as $source) { - $mergedFiles[$testFile][$source] = true; - } - } - - foreach ($data['tables'] as $testFile => $tables) { - if (! isset($mergedTables[$testFile])) { - $mergedTables[$testFile] = []; - } - - foreach ($tables as $table) { - $mergedTables[$testFile][$table] = true; - } - } - - foreach ($data['inertia'] as $testFile => $components) { - if (! isset($mergedInertia[$testFile])) { - $mergedInertia[$testFile] = []; - } - - foreach ($components as $component) { - $mergedInertia[$testFile][$component] = true; - } - } - - $this->state->delete($key); - } - - $finalised = []; - - foreach ($mergedFiles as $testFile => $sourceSet) { - $finalised[$testFile] = array_keys($sourceSet); - } - - $finalisedTables = []; - - foreach ($mergedTables as $testFile => $tableSet) { - $finalisedTables[$testFile] = array_keys($tableSet); - } - - $finalisedInertia = []; - - foreach ($mergedInertia as $testFile => $componentSet) { - $finalisedInertia[$testFile] = array_keys($componentSet); - } + [$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys); if ($finalised === []) { if ($this->replayRan) { @@ -1201,6 +1141,43 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $token; } + /** + * @param list $partialKeys + * @return array{0: array>, 1: array>, 2: array>} + */ + private function consumePartials(array $partialKeys): array + { + $merged = ['files' => [], 'tables' => [], 'inertia' => []]; + + foreach ($partialKeys as $key) { + $data = $this->readPartial($key); + + if ($data === null) { + continue; + } + + foreach (['files', 'tables', 'inertia'] as $section) { + foreach ($data[$section] as $testFile => $values) { + if (! isset($merged[$section][$testFile])) { + $merged[$section][$testFile] = []; + } + + foreach ($values as $value) { + $merged[$section][$testFile][$value] = true; + } + } + } + + $this->state->delete($key); + } + + return [ + array_map(array_keys(...), $merged['files']), + array_map(array_keys(...), $merged['tables']), + array_map(array_keys(...), $merged['inertia']), + ]; + } + /** * @return array{files: array>, tables: array>, inertia: array>}|null */ diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 0025c375..4a3fefe8 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -289,21 +289,7 @@ YAML; { $failureKind = null; - if (! $this->commandExists('gh')) { - Panic::with(new BaselineFetchFailed( - 'GitHub CLI (gh) not found — cannot fetch baseline.', - 'Install it from https://cli.github.com.', - $hasAnchor, - )); - } - - if (! $this->ghAuthenticated()) { - Panic::with(new BaselineFetchFailed( - 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', - 'Run `gh auth login` and retry.', - $hasAnchor, - )); - } + $this->validateGhDependencies($hasAnchor); [$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo); @@ -350,6 +336,41 @@ YAML; return null; } + if (! $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor, $failureKind)) { + return null; + } + + $payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor); + + $this->trimDownloadCache($projectRoot); + + return $payload; + } + + private function validateGhDependencies(bool $hasAnchor): void + { + if (! $this->commandExists('gh')) { + Panic::with(new BaselineFetchFailed( + 'GitHub CLI (gh) not found — cannot fetch baseline.', + 'Install it from https://cli.github.com.', + $hasAnchor, + )); + } + + if (! $this->ghAuthenticated()) { + Panic::with(new BaselineFetchFailed( + 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', + 'Run `gh auth login` and retry.', + $hasAnchor, + )); + } + } + + /** + * @param-out string|null $failureKind + */ + private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor, ?string &$failureKind): bool + { $artifactSize = $this->artifactSize($repo, $runId); $this->renderBadge('INFO', $artifactSize !== null @@ -382,28 +403,36 @@ YAML; $process->wait(); $this->clearProgressLine(); - if (! $process->isSuccessful()) { - $this->cleanup($runCacheDir); - - $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); - $failureKind = $diagnosis['kind']; - - if (in_array($failureKind, ['forbidden', 'not-found'], true)) { - Panic::with(new BaselineFetchFailed( - sprintf('Baseline download failed — %s', $diagnosis['message']), - 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.', - $hasAnchor, - )); - } - - $this->renderBadge('WARN', sprintf( - 'Baseline download failed — %s', - $diagnosis['message'], - )); - - return null; + if ($process->isSuccessful()) { + return true; } + $this->cleanup($runCacheDir); + + $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); + $failureKind = $diagnosis['kind']; + + if (in_array($failureKind, ['forbidden', 'not-found'], true)) { + Panic::with(new BaselineFetchFailed( + sprintf('Baseline download failed — %s', $diagnosis['message']), + 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.', + $hasAnchor, + )); + } + + $this->renderBadge('WARN', sprintf( + 'Baseline download failed — %s', + $diagnosis['message'], + )); + + return false; + } + + /** + * @return array{graph: string, coverage: ?string, sizeOnDisk: int} + */ + private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array + { $payload = $this->readArtifact($runCacheDir); if ($payload === null) { @@ -416,8 +445,6 @@ YAML; )); } - $this->trimDownloadCache($projectRoot); - return $payload; } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 10838707..5a7deb8c 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -64,30 +64,11 @@ final readonly class Fingerprint */ public static function structuralDrift(array $stored, array $current): array { - $a = self::structuralOnly($stored); - $b = self::structuralOnly($current); - - $drifts = []; - - foreach ($a as $key => $value) { - if ($key === 'schema') { - continue; - } - if (($b[$key] ?? null) !== $value) { - $drifts[] = $key; - } - } - - foreach ($b as $key => $value) { - if ($key === 'schema') { - continue; - } - if (! array_key_exists($key, $a) && $value !== null) { - $drifts[] = $key; - } - } - - return array_values(array_unique($drifts)); + return self::detectDrift( + self::structuralOnly($stored), + self::structuralOnly($current), + 'schema', + ); } /** @@ -97,18 +78,34 @@ final readonly class Fingerprint */ public static function environmentalDrift(array $stored, array $current): array { - $a = self::environmentalOnly($stored); - $b = self::environmentalOnly($current); + return self::detectDrift( + self::environmentalOnly($stored), + self::environmentalOnly($current), + ); + } + /** + * @param array $a + * @param array $b + * @return list + */ + private static function detectDrift(array $a, array $b, ?string $skipKey = null): array + { $drifts = []; foreach ($a as $key => $value) { + if ($key === $skipKey) { + continue; + } if (($b[$key] ?? null) !== $value) { $drifts[] = $key; } } foreach ($b as $key => $value) { + if ($key === $skipKey) { + continue; + } if (! array_key_exists($key, $a) && $value !== null) { $drifts[] = $key; } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index e23d0485..868d8c7f 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -86,37 +86,76 @@ final class Graph */ public function affected(array $changedFiles): array { - $normalised = []; + [$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles); + + $affectedSet = []; + + $unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet); + + [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved] + = $this->applyInertiaChanges($nonMigrationPaths, $affectedSet); + + $unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet); + + $this->applyTestFileChanges($nonMigrationPaths, $affectedSet); + + $staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet); + + $this->applyWatchPatternFallback( + $nonMigrationPaths, + $unparseableMigrations, + $preciselyHandledPages, + $sharedFilesResolved, + $staticallyHandledBlade, + $affectedSet, + ); + + $this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet); + + return array_keys($affectedSet); + } + + /** + * @param array $changedFiles + * @return array{0: list, 1: list} + */ + private function partitionChangedPaths(array $changedFiles): array + { + $migrations = []; + $nonMigrations = []; foreach ($changedFiles as $file) { $rel = $this->relative($file); - if ($rel !== null) { - $normalised[] = $rel; + if ($rel === null) { + continue; } - } - $affectedSet = []; - - $migrationPaths = []; - $nonMigrationPaths = []; - - foreach ($normalised as $rel) { if ($this->isMigrationPath($rel)) { - $migrationPaths[] = $rel; + $migrations[] = $rel; } else { - $nonMigrationPaths[] = $rel; + $nonMigrations[] = $rel; } } + return [$migrations, $nonMigrations]; + } + + /** + * @param list $migrationPaths + * @param array $affectedSet + * @return list Unparseable migrations (caller treats as unknown-to-graph). + */ + private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array + { $changedTables = []; - $unparseableMigrations = []; + $unparseable = []; foreach ($migrationPaths as $rel) { $tables = $this->tablesForMigration($rel); if ($tables === []) { - $unparseableMigrations[] = $rel; + $unparseable[] = $rel; continue; } @@ -142,6 +181,17 @@ final class Graph } } + return $unparseable; + } + + /** + * @param list $nonMigrationPaths + * @param array $affectedSet + * @return array{0: array, 1: array, 2: array} + * globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved + */ + private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array + { $globalFrontendRuntimeFiles = []; foreach ($nonMigrationPaths as $rel) { @@ -173,6 +223,7 @@ final class Graph } $sharedFilesResolved = []; + foreach ($nonMigrationPaths as $rel) { if (isset($globalFrontendRuntimeFiles[$rel])) { continue; @@ -180,12 +231,12 @@ final class Graph if (isset($preciselyHandledPages[$rel])) { continue; } - if (! isset($this->jsFileToComponents[$rel])) { continue; } $touchedAny = false; + foreach ($this->jsFileToComponents[$rel] as $pageComponent) { if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { $changedComponents[$pageComponent] = true; @@ -199,6 +250,7 @@ final class Graph } $newJsFiles = []; + foreach ($nonMigrationPaths as $rel) { if (isset($globalFrontendRuntimeFiles[$rel])) { continue; @@ -219,39 +271,7 @@ final class Graph } if ($newJsFiles !== []) { - $freshMap = JsModuleGraph::buildStrict($this->projectRoot); - - if ($freshMap === null) { - View::render('components.badge', [ - 'type' => 'WARN', - 'content' => sprintf( - 'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', - count($newJsFiles), - ), - ]); - } else { - foreach ($newJsFiles as $rel) { - $pages = $freshMap[$rel] ?? []; - - if ($pages === []) { - $sharedFilesResolved[$rel] = true; - - continue; - } - - $touchedAny = false; - foreach ($pages as $pageComponent) { - if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { - $changedComponents[$pageComponent] = true; - $touchedAny = true; - } - } - - if ($touchedAny) { - $sharedFilesResolved[$rel] = true; - } - } - } + $this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved); } if ($changedComponents !== []) { @@ -270,6 +290,61 @@ final class Graph } } + return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]; + } + + /** + * @param list $newJsFiles + * @param array $changedComponents + * @param array $sharedFilesResolved + */ + private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void + { + $freshMap = JsModuleGraph::buildStrict($this->projectRoot); + + if ($freshMap === null) { + View::render('components.badge', [ + 'type' => 'WARN', + 'content' => sprintf( + 'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', + count($newJsFiles), + ), + ]); + + return; + } + + foreach ($newJsFiles as $rel) { + $pages = $freshMap[$rel] ?? []; + + if ($pages === []) { + $sharedFilesResolved[$rel] = true; + + continue; + } + + $touchedAny = false; + + foreach ($pages as $pageComponent) { + if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { + $changedComponents[$pageComponent] = true; + $touchedAny = true; + } + } + + if ($touchedAny) { + $sharedFilesResolved[$rel] = true; + } + } + } + + /** + * @param list $nonMigrationPaths + * @param array $affectedSet + * @return array Unknown source dirs (sibling-heuristic). + */ + private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array + { $changedIds = []; $unknownSourceDirs = []; $sourcePhpChanged = false; @@ -286,9 +361,7 @@ final class Graph } if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) { - $absolute = $this->projectRoot.'/'.$rel; - - if (! is_file($absolute)) { + if (! is_file($this->projectRoot.'/'.$rel)) { continue; } @@ -320,8 +393,18 @@ 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). + return $unknownSourceDirs; + } + + /** + * A changed file inside the configured test suites is itself the unit of + * work — always run it (new untracked tests, edited tests, renames). + * + * @param list $nonMigrationPaths + * @param array $affectedSet + */ + private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void + { $testPaths = TestPaths::fromProjectRoot($this->projectRoot); foreach ($nonMigrationPaths as $rel) { @@ -336,9 +419,19 @@ final class Graph } $affectedSet[$rel] = true; } + } + + /** + * Unknown Blade files: walk static references (@include, @extends, ) up to rendered. + * + * @param list $nonMigrationPaths + * @param array $affectedSet + * @return array + */ + private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array + { + $staticallyHandled = []; - // Unknown Blade files: walk static references (@include, @extends, ) up to rendered - $staticallyHandledBlade = []; foreach ($nonMigrationPaths as $rel) { if (isset($this->fileIds[$rel])) { continue; @@ -357,13 +450,33 @@ final class Graph $affectedSet[$testFile] = true; } - $staticallyHandledBlade[$rel] = true; + $staticallyHandled[$rel] = true; } elseif ($this->isBladeComponentPath($rel)) { - $staticallyHandledBlade[$rel] = true; + $staticallyHandled[$rel] = true; } } + return $staticallyHandled; + } + + /** + * @param list $nonMigrationPaths + * @param list $unparseableMigrations + * @param array $preciselyHandledPages + * @param array $sharedFilesResolved + * @param array $staticallyHandledBlade + * @param array $affectedSet + */ + private function applyWatchPatternFallback( + array $nonMigrationPaths, + array $unparseableMigrations, + array $preciselyHandledPages, + array $sharedFilesResolved, + array $staticallyHandledBlade, + array &$affectedSet, + ): void { $unknownToGraph = $unparseableMigrations; + foreach ($nonMigrationPaths as $rel) { if (isset($preciselyHandledPages[$rel])) { continue; @@ -392,30 +505,37 @@ final class Graph foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { $affectedSet[$testFile] = true; } + } - if ($unknownSourceDirs !== []) { - foreach ($this->edges as $testFile => $ids) { - if (isset($affectedSet[$testFile])) { + /** + * @param array $unknownSourceDirs + * @param array $affectedSet + */ + private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void + { + if ($unknownSourceDirs === []) { + return; + } + + foreach ($this->edges as $testFile => $ids) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($ids as $id) { + if (! isset($this->files[$id])) { continue; } - foreach ($ids as $id) { - if (! isset($this->files[$id])) { - continue; - } + $depDir = dirname($this->files[$id]); - $depDir = dirname($this->files[$id]); + if (isset($unknownSourceDirs[$depDir])) { + $affectedSet[$testFile] = true; - if (isset($unknownSourceDirs[$depDir])) { - $affectedSet[$testFile] = true; - - break; - } + break; } } } - - return array_keys($affectedSet); } public function knowsTest(string $testFile): bool @@ -1229,78 +1349,51 @@ final class Graph $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; $graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : []; - if (isset($data['test_tables']) && is_array($data['test_tables'])) { - foreach ($data['test_tables'] as $testRel => $tables) { - if (! is_string($testRel)) { - continue; - } - if (! is_array($tables)) { - continue; - } - $names = []; - - foreach ($tables as $table) { - if (is_string($table) && $table !== '') { - $names[] = $table; - } - } - - if ($names !== []) { - $graph->testTables[$testRel] = $names; - } - } - } - - if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) { - foreach ($data['test_inertia_components'] as $testRel => $components) { - if (! is_string($testRel)) { - continue; - } - if (! is_array($components)) { - continue; - } - $names = []; - - foreach ($components as $component) { - if (is_string($component) && $component !== '') { - $names[] = $component; - } - } - - if ($names !== []) { - $graph->testInertiaComponents[$testRel] = $names; - } - } - } - - if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) { - foreach ($data['js_file_to_components'] as $path => $components) { - if (! is_string($path)) { - continue; - } - if ($path === '') { - continue; - } - if (! is_array($components)) { - continue; - } - $names = []; - - foreach ($components as $component) { - if (is_string($component) && $component !== '') { - $names[] = $component; - } - } - - if ($names !== []) { - $graph->jsFileToComponents[$path] = $names; - } - } - } + $graph->testTables = self::decodeStringMap($data['test_tables'] ?? null); + $graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null); + $graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null); return $graph; } + /** + * @return array> + */ + private static function decodeStringMap(mixed $section): array + { + if (! is_array($section)) { + return []; + } + + $out = []; + + foreach ($section as $key => $values) { + if (! is_string($key)) { + continue; + } + if ($key === '') { + continue; + } + if (! is_array($values)) { + continue; + } + + $names = []; + + foreach ($values as $value) { + if (is_string($value) && $value !== '') { + $names[] = $value; + } + } + + if ($names !== []) { + $out[$key] = $names; + } + } + + return $out; + } + public function encode(): ?string { $payload = [ diff --git a/src/Plugins/Tia/SourceScope.php b/src/Plugins/Tia/SourceScope.php index 68e060d0..325b3e12 100644 --- a/src/Plugins/Tia/SourceScope.php +++ b/src/Plugins/Tia/SourceScope.php @@ -15,7 +15,6 @@ final class SourceScope /** @var array */ private array $containsCache = []; - private const array TOP_LEVEL_NOISE = [ 'vendor', 'node_modules',