This commit is contained in:
nuno maduro
2026-05-01 20:42:14 +01:00
parent a725e774c0
commit a349f53964
7 changed files with 60 additions and 93 deletions

View File

@ -274,24 +274,11 @@ trait Testable
self::$__latestIssues = $method->issues; self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs; self::$__latestPrs = $method->prs;
// TIA replay short-circuit. Runs AFTER dataset/description/
// assignee metadata is populated so output and filtering still
// see the correct test name + tags on a cache hit, but BEFORE
// `parent::setUp()` and `beforeEach` so we skip the user's
// fixture setup (which is the whole point of replay — avoid
// paying for work whose outcome we already know).
/** @var Tia $tia */ /** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class); $tia = Container::getInstance()->get(Tia::class);
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); $cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
if ($cached !== null) { if ($cached !== null) {
// Risky has no public PHPUnit hook to replay as-risky, so we
// collapse it into Pass — the test doesn't misreport as a
// failure, at the cost of losing aggregate risky totals on
// replay (accepted until PHPUnit grows a programmatic
// risky-marker API). Skipped/Incomplete throw the matching
// PHPUnit exception so the runner marks the status exactly
// as it did on the recorded run.
match (Replay::from($cached)) { match (Replay::from($cached)) {
Replay::Pass => $this->__shortCircuitCachedPass(), Replay::Pass => $this->__shortCircuitCachedPass(),
Replay::Skipped => $this->markTestSkipped($cached->message()), Replay::Skipped => $this->markTestSkipped($cached->message()),

View File

@ -357,14 +357,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->seedResultsInto($graph); $this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->renderBadge('ERROR', 'TIA could not write the dependency graph.'); $this->renderBadge('ERROR', 'Could not write the dependency graph.');
$recorder->reset(); $recorder->reset();
return; return;
} }
$this->renderBadge('INFO', sprintf( $this->renderBadge('INFO', sprintf(
'TIA recorded the dependency graph (%d test file%s).', 'Recorded the dependency graph (%d test file%s).',
count($perTest), count($perTest),
count($perTest) === 1 ? '' : 's', count($perTest) === 1 ? '' : 's',
)); ));
@ -488,7 +488,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode; return $exitCode;
} }
$this->renderBadge('ERROR', 'TIA recorded zero edges — coverage driver likely missing.'); $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->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.');
return $exitCode; return $exitCode;
@ -504,13 +504,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->renderBadge('ERROR', 'TIA could not write the dependency graph.'); $this->renderBadge('ERROR', 'Could not write the dependency graph.');
return $exitCode; return $exitCode;
} }
$this->renderBadge('INFO', sprintf( $this->renderBadge('INFO', sprintf(
'TIA recorded the dependency graph (%d test file%s, %d worker partial%s).', 'Recorded the dependency graph (%d test file%s, %d worker partial%s).',
count($finalised), count($finalised),
count($finalised) === 1 ? '' : 's', count($finalised) === 1 ? '' : 's',
count($partialKeys), count($partialKeys),
@ -536,8 +536,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! Fingerprint::structuralMatches($stored, $current)) { if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current); $drift = Fingerprint::structuralDrift($stored, $current);
$this->renderBadge('WARN', sprintf( $this->renderBadge('INFO', sprintf(
'TIA graph structure outdated (%s).', 'Graph structure outdated (%s).',
$this->formatStructuralDrift($drift), $this->formatStructuralDrift($drift),
)); ));
@ -560,7 +560,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->reconcileFingerprint($rebuilt, $current); return $this->reconcileFingerprint($rebuilt, $current);
} }
$this->renderBadge('WARN', 'TIA rebuilding graph from scratch.'); $this->renderBadge('WARN', 'Rebuilding graph from scratch.');
$this->state->delete(self::KEY_GRAPH); $this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE); $this->state->delete(self::KEY_COVERAGE_CACHE);
@ -572,7 +572,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($drift !== []) { if ($drift !== []) {
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'TIA env differs from baseline (%s) — results dropped, edges reused.', 'Env differs from baseline (%s) — results dropped, edges reused.',
implode(', ', $drift), implode(', ', $drift),
)); ));
@ -621,7 +621,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($changedFiles->gitAvailable() if ($changedFiles->gitAvailable()
&& $branchSha !== null && $branchSha !== null
&& $changedFiles->since($branchSha) === null) { && $changedFiles->since($branchSha) === null) {
$this->renderBadge('WARN', 'TIA recorded commit is no longer reachable — graph will be rebuilt.'); $this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
$graph = null; $graph = null;
} }
} }
@ -794,7 +794,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
if (! $changedFiles->gitAvailable()) { if (! $changedFiles->gitAvailable()) {
$this->renderBadge('WARN', 'TIA git unavailable — running full suite.'); $this->renderBadge('WARN', 'Git unavailable — running full suite.');
return $arguments; return $arguments;
} }
@ -811,9 +811,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable(); $coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) { if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
$this->renderBadge('WARN', 'TIA detected PHP source changes but no coverage driver is available.'); $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('Running the full suite to avoid using a stale dependency graph.');
$this->renderDetail('Install / enable pcov or xdebug (mode: coverage) so TIA can safely refresh edges after PHP refactors.'); $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
return $arguments; return $arguments;
} }
@ -867,7 +867,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! $this->persistAffectedSet($affected)) { if (! $this->persistAffectedSet($affected)) {
$this->renderBadge('ERROR', 'TIA could not persist affected set — running full suite.'); $this->renderBadge('ERROR', 'Could not persist affected set — running full suite.');
return $arguments; return $arguments;
} }
@ -1013,9 +1013,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
$this->renderBadge('INFO', $this->piggybackCoverage $this->renderBadge('INFO', $this->piggybackCoverage
? 'TIA recording dependency graph in parallel via --coverage (first run) — '. ? 'Recording dependency graph in parallel via --coverage (first run) — '.
'subsequent --tia runs will only re-execute affected tests.' 'subsequent --tia runs will only re-execute affected tests.'
: 'TIA recording dependency graph in parallel (first run) — '. : 'Recording dependency graph in parallel (first run) — '.
'subsequent --tia runs will only re-execute affected tests.'); 'subsequent --tia runs will only re-execute affected tests.');
return $arguments; return $arguments;
@ -1024,7 +1024,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($this->piggybackCoverage) { if ($this->piggybackCoverage) {
$this->recordingActive = true; $this->recordingActive = true;
$this->renderBadge('INFO', 'TIA recording dependency graph via --coverage (first run) — '. $this->renderBadge('INFO', 'Recording dependency graph via --coverage (first run) — '.
'subsequent --tia runs will only re-execute affected tests.'); 'subsequent --tia runs will only re-execute affected tests.');
return $arguments; return $arguments;
@ -1034,7 +1034,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->recordingActive = true; $this->recordingActive = true;
$this->renderBadge('INFO', sprintf( $this->renderBadge('INFO', sprintf(
'TIA recording dependency graph via %s (first run) — '. 'Recording dependency graph via %s (first run) — '.
'subsequent --tia runs will only re-execute affected tests.', 'subsequent --tia runs will only re-execute affected tests.',
$recorder->driver(), $recorder->driver(),
)); ));
@ -1044,8 +1044,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function emitCoverageDriverMissing(): void private function emitCoverageDriverMissing(): void
{ {
$this->renderBadge('WARN', 'No coverage driver is available — TIA skipped.'); $this->renderBadge('WARN', 'No coverage driver is available — skipped.');
$this->renderDetail('TIA needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.'); $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->renderDetail('Install or enable one and rerun with --tia.');
} }
@ -1069,6 +1069,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write($this->workerEdgesKey($this->workerToken()), $json); $this->state->write($this->workerEdgesKey($this->workerToken()), $json);
} }
/**
* @return list<string>
*/
private function collectWorkerEdgesPartials(): array private function collectWorkerEdgesPartials(): array
{ {
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
@ -1128,6 +1131,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write($this->workerResultsKey($this->workerToken()), $json); $this->state->write($this->workerResultsKey($this->workerToken()), $json);
} }
/**
* @return list<string>
*/
private function collectWorkerReplayPartials(): array private function collectWorkerReplayPartials(): array
{ {
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
@ -1367,7 +1373,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// path from the class embedded in the test ID — without it, // path from the class embedded in the test ID — without it,
// filtered runs lose the ability to re-run only the failing test // filtered runs lose the ability to re-run only the failing test
// next time. // next time.
if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) { if ($file === null || str_contains($file, "eval()'d")) {
$file = $this->resolveFailedTestFile($testId); $file = $this->resolveFailedTestFile($testId);
} }
@ -1406,15 +1412,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
{ {
$class = strstr($testId, '::', true); $class = strstr($testId, '::', true);
if (! is_string($class) || $class === '') { if (! is_string($class) || $class === '' || ! class_exists($class)) {
return null; return null;
} }
try { $reflection = new \ReflectionClass($class);
$reflection = new \ReflectionClass($class);
} catch (\ReflectionException) {
return null;
}
if ($reflection->hasProperty('__filename')) { if ($reflection->hasProperty('__filename')) {
try { try {
@ -1433,7 +1435,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
while ($current !== false) { while ($current !== false) {
$file = $current->getFileName(); $file = $current->getFileName();
if (is_string($file) && $file !== '' && ! str_contains($file, "eval()'d")) { if ($file !== false && ! str_contains($file, "eval()'d")) {
return $file; return $file;
} }
@ -1466,10 +1468,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function hasProjectPhpSourceChanges(array $changedFiles): bool private function hasProjectPhpSourceChanges(array $changedFiles): bool
{ {
foreach ($changedFiles as $rel) { foreach ($changedFiles as $rel) {
if (! is_string($rel)) {
continue;
}
if (! str_ends_with($rel, '.php')) { if (! str_ends_with($rel, '.php')) {
continue; continue;
} }
@ -1523,12 +1521,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) { if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
$this->renderBadge('WARN', 'TIA fetched baseline still drifts — discarding.'); $this->renderBadge('WARN', 'Fetched baseline still drifts — discarding.');
return null; return null;
} }
$this->renderBadge('SUCCESS', 'TIA fetched baseline matches — skipping local rebuild.'); $this->renderBadge('SUCCESS', 'Fetched baseline matches — skipping local rebuild.');
return $fetched; return $fetched;
} }

View File

@ -67,7 +67,7 @@ final readonly class BaselineSync
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'TIA last fetch found no baseline — next auto-retry in %s. Override with --refetch.', 'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
$this->formatDuration($remaining), $this->formatDuration($remaining),
)); ));
@ -102,7 +102,7 @@ final readonly class BaselineSync
$this->clearCooldown(); $this->clearCooldown();
$this->renderBadge('SUCCESS', sprintf( $this->renderBadge('SUCCESS', sprintf(
'TIA baseline ready (%s).', 'Baseline ready (%s).',
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
)); ));
@ -156,7 +156,7 @@ final readonly class BaselineSync
private function emitPublishInstructions(string $repo): void private function emitPublishInstructions(string $repo): void
{ {
if ($this->isCi()) { if ($this->isCi()) {
$this->renderBadge('INFO', 'TIA no baseline yet — this run will produce one.'); $this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
return; return;
} }
@ -165,7 +165,7 @@ final readonly class BaselineSync
? $this->laravelWorkflowYaml() ? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml(); : $this->genericWorkflowYaml();
$this->renderBadge('WARN', 'TIA no baseline published yet — recording locally.'); $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('To share the baseline with your team, add this workflow to the repo:');
$this->renderDetail('.github/workflows/tia-baseline.yml'); $this->renderDetail('.github/workflows/tia-baseline.yml');
@ -339,7 +339,7 @@ YAML;
// Tier 2 — transient (network, rate-limit, unknown). Surface // Tier 2 — transient (network, rate-limit, unknown). Surface
// the diagnostic but let the suite fall through to record mode. // the diagnostic but let the suite fall through to record mode.
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'TIA failed to query baseline runs — %s', 'Failed to query baseline runs — %s',
$listError['message'], $listError['message'],
)); ));
@ -364,7 +364,7 @@ YAML;
@touch($runCacheDir); @touch($runCacheDir);
$this->renderBadge('INFO', sprintf( $this->renderBadge('INFO', sprintf(
'TIA using cached baseline from %s (run %s).', 'Using cached baseline from %s (run %s).',
$repo, $repo,
$runId, $runId,
)); ));
@ -380,12 +380,12 @@ YAML;
$this->renderBadge('INFO', $artifactSize !== null $this->renderBadge('INFO', $artifactSize !== null
? sprintf( ? sprintf(
'TIA fetching baseline (%s) from %s…', 'Fetching baseline (%s) from %s…',
$this->formatSize($artifactSize), $this->formatSize($artifactSize),
$repo, $repo,
) )
: sprintf( : sprintf(
'TIA fetching baseline from %s…', 'Fetching baseline from %s…',
$repo, $repo,
)); ));
@ -424,7 +424,7 @@ YAML;
// Tier 2 — transient. Diagnostic + fall through to record mode. // Tier 2 — transient. Diagnostic + fall through to record mode.
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'TIA baseline download failed — %s', 'Baseline download failed — %s',
$diagnosis['message'], $diagnosis['message'],
)); ));
@ -491,7 +491,7 @@ YAML;
// "100%" message naturally. // "100%" message naturally.
$percent = min(99, (int) floor(($current / $totalBytes) * 100)); $percent = min(99, (int) floor(($current / $totalBytes) * 100));
$message = sprintf( $message = sprintf(
' <fg=cyan>TIA</> downloading %s / %s (%d%%, %s/s)', ' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
$this->formatSize($current), $this->formatSize($current),
$this->formatSize($totalBytes), $this->formatSize($totalBytes),
$percent, $percent,
@ -499,7 +499,7 @@ YAML;
); );
} else { } else {
$message = sprintf( $message = sprintf(
' <fg=cyan>TIA</> downloading %s (%s/s)', ' <fg=cyan>Downloading</> %s (%s/s)',
$this->formatSize($current), $this->formatSize($current),
$this->formatSize($speed), $this->formatSize($speed),
); );
@ -723,8 +723,7 @@ YAML;
// Unknown — surface the first informative line so the user has // Unknown — surface the first informative line so the user has
// *something* to act on. // *something* to act on.
$first = strtok($output, "\n"); $message = trim(strtok($output, "\n"));
$message = is_string($first) ? trim($first) : 'unknown error';
return ['kind' => 'unknown', 'message' => $message]; return ['kind' => 'unknown', 'message' => $message];
} }

View File

@ -234,7 +234,7 @@ final class Graph
View::render('components.badge', [ View::render('components.badge', [
'type' => 'WARN', 'type' => 'WARN',
'content' => sprintf( 'content' => sprintf(
'TIA Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', 'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles), count($newJsFiles),
), ),
]); ]);
@ -469,7 +469,8 @@ final class Graph
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [
$entry = [
'status' => $status, 'status' => $status,
'message' => $message, 'message' => $message,
'time' => $time, 'time' => $time,
@ -480,9 +481,11 @@ final class Graph
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel !== null) { if ($rel !== null) {
$this->baselines[$branch]['results'][$testId]['file'] = $rel; $entry['file'] = $rel;
} }
} }
$this->baselines[$branch]['results'][$testId] = $entry;
} }
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
@ -532,17 +535,12 @@ final class Graph
$files = []; $files = [];
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null; if ($result['status'] !== 7 && $result['status'] !== 8) {
if ($status !== 7 && $status !== 8) {
continue; continue;
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if (! is_string($file)) { if ($file === null || $file === '') {
continue;
}
if ($file === '') {
continue; continue;
} }
@ -561,15 +559,13 @@ final class Graph
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null; if ($result['status'] !== 7 && $result['status'] !== 8) {
if ($status !== 7 && $status !== 8) {
continue; continue;
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if (! is_string($file) || $file === '' || $this->relative($file) === null) { if ($file === null || $file === '' || $this->relative($file) === null) {
return true; return true;
} }
} }

View File

@ -473,7 +473,7 @@ final class Recorder
} }
$parts = preg_split('/\s+as\s+/i', $import); $parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false || $parts === []) { if ($parts === false) {
return null; return null;
} }
@ -488,9 +488,6 @@ final class Recorder
if (! is_array($loader)) { if (! is_array($loader)) {
continue; continue;
} }
if (! isset($loader[0])) {
continue;
}
if (! is_object($loader[0])) { if (! is_object($loader[0])) {
continue; continue;
} }
@ -689,9 +686,6 @@ final class Recorder
$out = []; $out = [];
foreach ($data as $file => $lines) { foreach ($data as $file => $lines) {
if (! is_string($file)) {
continue;
}
if (! is_array($lines)) { if (! is_array($lines)) {
continue; continue;
} }
@ -709,7 +703,8 @@ final class Recorder
// Skip files where the only "executed" line is the implicit // Skip files where the only "executed" line is the implicit
// ZEND_RETURN at end-of-file (pcov artifact from being included // ZEND_RETURN at end-of-file (pcov artifact from being included
// but never actually run). // but never actually run).
if (count($covered) === 1 && max($covered) === max(array_keys($lines))) { $lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue; continue;
} }

View File

@ -77,7 +77,7 @@ final readonly class SourceScope
$includes = [self::normalise($projectRoot)]; $includes = [self::normalise($projectRoot)];
} }
return new self($projectRoot, $includes, $excludes); return new self($includes, $excludes);
} }
/** /**
@ -152,13 +152,7 @@ final readonly class SourceScope
continue; continue;
} }
$absolute = self::resolveRelative($value, $configDir); $out[] = self::resolveRelative($value, $configDir);
if ($absolute === null) {
continue;
}
$out[] = $absolute;
} }
return array_values(array_unique($out)); return array_values(array_unique($out));

View File

@ -79,9 +79,7 @@ final class PcovRestarter implements Restarter
$env = []; $env = [];
foreach (getenv() as $name => $value) { foreach (getenv() as $name => $value) {
if (is_string($value)) { $env[$name] = $value;
$env[$name] = $value;
}
} }
return $env; return $env;