This commit is contained in:
nuno maduro
2026-05-02 17:45:54 +01:00
parent a07a2e512a
commit 89f3d6cb39
5 changed files with 358 additions and 265 deletions

View File

@ -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<string> $partialKeys
* @return array{0: array<string, list<string>>, 1: array<string, list<string>>, 2: array<string, list<string>>}
*/
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<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
*/

View File

@ -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;
}

View File

@ -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<string, mixed> $a
* @param array<string, mixed> $b
* @return list<string>
*/
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;
}

View File

@ -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<int, string> $changedFiles
* @return array{0: list<string>, 1: list<string>}
*/
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<string> $migrationPaths
* @param array<string, true> $affectedSet
* @return list<string> 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<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
* 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<string> $newJsFiles
* @param array<string, true> $changedComponents
* @param array<string, true> $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<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true> 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<string> $nonMigrationPaths
* @param array<string, true> $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, <x-*>) up to rendered.
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true>
*/
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$staticallyHandled = [];
// Unknown Blade files: walk static references (@include, @extends, <x-*>) 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<string> $nonMigrationPaths
* @param list<string> $unparseableMigrations
* @param array<string, true> $preciselyHandledPages
* @param array<string, true> $sharedFilesResolved
* @param array<string, true> $staticallyHandledBlade
* @param array<string, true> $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<string, true> $unknownSourceDirs
* @param array<string, true> $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<string, list<string>>
*/
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 = [

View File

@ -15,7 +15,6 @@ final class SourceScope
/** @var array<string, bool> */
private array $containsCache = [];
private const array TOP_LEVEL_NOISE = [
'vendor',
'node_modules',