diff --git a/README.md b/README.md
index af33fd43..1967de7b 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@
+
diff --git a/bin/pest b/bin/pest
index 8cd27788..a6f87492 100755
--- a/bin/pest
+++ b/bin/pest
@@ -3,8 +3,10 @@
declare(strict_types=1);
+use Pest\Contracts\Restarter;
use Pest\Kernel;
use Pest\Panic;
+use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
@@ -142,6 +144,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2);
+
$input = new ArgvInput;
$testSuite = TestSuite::getInstance(
@@ -192,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try {
$kernel = Kernel::boot($testSuite, $input, $output);
+ $container = Container::getInstance();
+
+ foreach (Kernel::RESTARTERS as $restarterClass) {
+ $restarter = $container->get($restarterClass);
+ assert($restarter instanceof Restarter);
+
+ $restarter->maybeRestart($rootPath, $originalArguments);
+ }
+
$result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate();
diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs
new file mode 100644
index 00000000..49133249
--- /dev/null
+++ b/bin/pest-tia-vite-deps.mjs
@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+
+import { readdir, readFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { resolve, relative, extname, sep, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+const PAGE_EXTENSIONS = new Set([
+ '.vue', '.svelte',
+ '.tsx', '.jsx',
+ '.ts', '.js',
+ '.mts', '.cts', '.mjs', '.cjs',
+])
+const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
+const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
+const PAGE_DIR_CANDIDATES = [
+ 'resources/js/Pages',
+ 'resources/js/pages',
+ 'assets/js/Pages',
+ 'assets/js/pages',
+ 'assets/Pages',
+ 'assets/pages',
+]
+
+async function loadRolldown() {
+ const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
+ const path = projectRequire.resolve('rolldown')
+ return await import(pathToFileURL(path).href)
+}
+
+async function readJsonWithComments(path) {
+ const raw = await readFile(path, 'utf8')
+ const stripped = raw
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1')
+ return JSON.parse(stripped)
+}
+
+async function loadAliasFromTsconfig() {
+ const alias = {}
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
+ const p = join(PROJECT_ROOT, name)
+ if (!existsSync(p)) continue
+ let cfg
+ try { cfg = await readJsonWithComments(p) } catch { continue }
+ const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
+ const paths = cfg?.compilerOptions?.paths ?? {}
+ for (const [key, targets] of Object.entries(paths)) {
+ if (!key.endsWith('/*')) continue
+ const t0 = Array.isArray(targets) ? targets[0] : null
+ if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
+ const aliasKey = key.slice(0, -2)
+ if (alias[aliasKey] !== undefined) continue
+ alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
+ }
+ }
+ return alias
+}
+
+async function listPageFiles(pagesDir) {
+ if (!existsSync(pagesDir)) return []
+
+ const out = []
+ const walk = async (dir) => {
+ let entries
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
+ for (const entry of entries) {
+ const full = resolve(dir, entry.name)
+ if (entry.isDirectory()) { await walk(full); continue }
+ if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
+ }
+ }
+
+ await walk(pagesDir)
+ return out
+}
+
+async function discoverPagesDir() {
+ const override = process.env.TIA_VITE_PAGES_DIR
+ if (override && override.length > 0) {
+ return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
+ }
+
+ for (const rel of PAGE_DIR_CANDIDATES) {
+ const abs = resolve(PROJECT_ROOT, rel)
+ if (!existsSync(abs)) continue
+ const files = await listPageFiles(abs)
+ if (files.length > 0) return abs
+ }
+
+ return null
+}
+
+function componentNameFor(pageAbs, pagesDir) {
+ const rel = relative(pagesDir, pageAbs).split(sep).join('/')
+ const ext = extname(rel)
+ return rel.slice(0, rel.length - ext.length)
+}
+
+function isLocalSpecifier(source, aliasKeys) {
+ if (source.startsWith('.') || source.startsWith('/')) return true
+ for (const key of aliasKeys) {
+ if (source === key || source.startsWith(key + '/')) return true
+ }
+ return false
+}
+
+async function main() {
+ const pagesDir = await discoverPagesDir()
+
+ if (pagesDir === null) {
+ process.stdout.write('{}')
+ return
+ }
+
+ const pages = await listPageFiles(pagesDir)
+
+ if (pages.length === 0) {
+ process.stdout.write('{}')
+ return
+ }
+
+ const { rolldown } = await loadRolldown()
+ const alias = await loadAliasFromTsconfig()
+ const aliasKeys = Object.keys(alias)
+
+ const graph = new Map()
+
+ const collector = {
+ name: 'pest-tia-collector',
+ moduleParsed(info) {
+ const id = info.id
+ if (!id || id.startsWith('\0')) return
+ const deps = new Set()
+ for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
+ for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
+ graph.set(id, deps)
+ },
+ }
+
+ const externalBare = {
+ name: 'pest-tia-external-bare',
+ resolveId(source) {
+ if (!source) return null
+ if (isLocalSpecifier(source, aliasKeys)) return null
+ return { id: source, external: true }
+ },
+ }
+
+ const assetStub = {
+ name: 'pest-tia-asset-stub',
+ load(id) {
+ if (!id) return null
+ if (ASSET_EXT_RE.test(id)) {
+ return { code: 'export default null', moduleSideEffects: false }
+ }
+ return null
+ },
+ }
+
+ const input = Object.create(null)
+ for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
+
+ const bundle = await rolldown({
+ input,
+ cwd: PROJECT_ROOT,
+ resolve: {
+ alias,
+ extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
+ },
+ transform: { jsx: 'preserve' },
+ treeshake: false,
+ plugins: [externalBare, assetStub, collector],
+ logLevel: 'silent',
+ onLog: () => {},
+ })
+
+ try {
+ await bundle.generate({ format: 'esm' })
+ } finally {
+ await bundle.close()
+ }
+
+ const reverse = new Map()
+ const transitiveCache = new Map()
+
+ const computeTransitive = (id, stack) => {
+ const cached = transitiveCache.get(id)
+ if (cached) return cached
+ if (stack.has(id)) return null
+
+ stack.add(id)
+ const acc = new Set()
+ const deps = graph.get(id)
+ if (deps) {
+ for (const dep of deps) {
+ if (!dep || dep.startsWith('\0')) continue
+ if (dep.startsWith(PROJECT_ROOT)) {
+ const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
+ acc.add(rel)
+ }
+ if (stack.has(dep)) continue
+ const child = computeTransitive(dep, stack)
+ if (child) for (const r of child) acc.add(r)
+ }
+ }
+ stack.delete(id)
+ transitiveCache.set(id, acc)
+ return acc
+ }
+
+ for (const page of pages) {
+ const pageComponent = componentNameFor(page, pagesDir)
+ const reachable = computeTransitive(page, new Set())
+ if (!reachable) continue
+ for (const rel of reachable) {
+ const bucket = reverse.get(rel) ?? new Set()
+ bucket.add(pageComponent)
+ reverse.set(rel, bucket)
+ }
+ }
+
+ const payload = Object.create(null)
+ const keys = [...reverse.keys()].sort()
+ for (const key of keys) {
+ payload[key] = [...reverse.get(key)].sort()
+ }
+
+ process.stdout.write(JSON.stringify(payload))
+}
+
+try {
+ void pathToFileURL
+ await main()
+} catch (err) {
+ process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
+ process.exit(1)
+}
diff --git a/bin/worker.php b/bin/worker.php
index dc69d67e..7b374785 100644
--- a/bin/worker.php
+++ b/bin/worker.php
@@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments;
+use Pest\Support\Container;
use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
@@ -58,6 +59,15 @@ $bootPest = (static function (): void {
}
}
+ $container = Container::getInstance();
+ $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
+
+ foreach (Kernel::RESTARTERS as $restarterClass) {
+ $restarter = $container->get($restarterClass);
+
+ $restarter->maybeRestart($rootPath, $_SERVER['argv']);
+ }
+
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile));
diff --git a/composer.json b/composer.json
index e2055426..03246b52 100644
--- a/composer.json
+++ b/composer.json
@@ -19,18 +19,18 @@
"require": {
"php": "^8.4",
"brianium/paratest": "^7.22.3",
- "nunomaduro/collision": "^8.9.3",
+ "nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^5.0.0",
"pestphp/pest-plugin-profanity": "^5.0.0",
- "phpunit/phpunit": "^13.1.7",
+ "phpunit/phpunit": "^13.1.8",
"symfony/process": "^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
- "phpunit/phpunit": ">13.1.7",
+ "phpunit/phpunit": ">13.1.8",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@@ -58,8 +58,8 @@
]
},
"require-dev": {
- "mrpunyapal/peststan": "^0.2.5",
- "nunomaduro/pao": "0.x-dev",
+ "mrpunyapal/peststan": "^0.2.9",
+ "laravel/pao": "^1.0.6",
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^5.0.0",
@@ -124,6 +124,7 @@
"Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard",
+ "Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel"
]
},
diff --git a/resources/views/components/badge.php b/resources/views/components/badge.php
index 39d31b9f..7e6ebee3 100644
--- a/resources/views/components/badge.php
+++ b/resources/views/components/badge.php
@@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'],
+ 'WARN' => ['yellow', 'WARN'],
+ 'SUCCESS' => ['green', 'SUCCESS'],
};
?>
diff --git a/src/Bootstrappers/BootPhpUnitConfiguration.php b/src/Bootstrappers/BootPhpUnitConfiguration.php
new file mode 100644
index 00000000..c9f80812
--- /dev/null
+++ b/src/Bootstrappers/BootPhpUnitConfiguration.php
@@ -0,0 +1,19 @@
+build(['pest']);
+ }
+}
diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php
index 7877b237..065a5e0f 100644
--- a/src/Bootstrappers/BootSubscribers.php
+++ b/src/Bootstrappers/BootSubscribers.php
@@ -25,6 +25,17 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class,
+ Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
+ Subscribers\EnsureTiaStarts::class,
+ Subscribers\EnsureTiaEnds::class,
+ Subscribers\EnsureTiaResultsAreCollected::class,
+ Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
+ Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
+ Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
+ Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
+ Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
+ Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
+ Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
];
/**
diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php
index 3f7e3b77..a6f78d25 100644
--- a/src/Concerns/Testable.php
+++ b/src/Concerns/Testable.php
@@ -7,12 +7,18 @@ namespace Pest\Concerns;
use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
+use Pest\Plugins\Tia;
+use Pest\Plugins\Tia\Collectors;
+use Pest\Plugins\Tia\Enums\ReplayType;
+use Pest\Plugins\Tia\Recorder;
use Pest\Preset;
use Pest\Support\ChainableClosure;
+use Pest\Support\Container;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\TestSuite;
+use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest;
@@ -75,6 +81,17 @@ trait Testable
*/
public bool $__ran = false;
+ /**
+ * The active replay mode for this test, set in `setUp()` and checked
+ * in `__runTest()` / `tearDown()` to skip the body and after-each.
+ */
+ private ReplayType $__replay = ReplayType::None;
+
+ /**
+ * The cached assertion count to replay, captured when entering replay mode.
+ */
+ private int $__replayAssertions = 0;
+
/**
* The test's test closure.
*/
@@ -259,8 +276,35 @@ trait Testable
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
+ /** @var Tia $tia */
+ $tia = Container::getInstance()->get(Tia::class);
+ $status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
+ $replay = ReplayType::fromStatus($status);
+
+ if ($replay !== ReplayType::None) {
+ assert($status !== null);
+
+ match ($replay) {
+ ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
+ ReplayType::Skipped => $this->markTestSkipped($status->message()),
+ ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
+ ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
+ };
+
+ return;
+ }
+
+ $recorder = Container::getInstance()->get(Recorder::class);
+ assert($recorder instanceof Recorder);
+
+ if ($recorder->isActive()) {
+ $recorder->beginTest($this::class, $this->name(), self::$__filename);
+ }
+
parent::setUp();
+ Collectors::armAll($recorder);
+
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) {
@@ -270,6 +314,13 @@ trait Testable
$this->__callClosure($beforeEach, $arguments);
}
+ private function __beginReplay(ReplayType $replay, Tia $tia): void
+ {
+ $this->__replay = $replay;
+ $this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
+ $this->__ran = true;
+ }
+
/**
* Initialize test case properties from TestSuite.
*/
@@ -302,6 +353,12 @@ trait Testable
*/
protected function tearDown(...$arguments): void
{
+ if ($this->__replay !== ReplayType::None) {
+ TestSuite::getInstance()->test = null;
+
+ return;
+ }
+
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) {
@@ -327,6 +384,16 @@ trait Testable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
+ if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
+ if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
+ $this->expectNotToPerformAssertions();
+ }
+
+ $this->addToAssertionCount($this->__replayAssertions);
+
+ return null;
+ }
+
$arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
diff --git a/src/Configuration.php b/src/Configuration.php
index 35a32ff0..3290bcf3 100644
--- a/src/Configuration.php
+++ b/src/Configuration.php
@@ -119,6 +119,14 @@ final readonly class Configuration
return new Browser\Configuration;
}
+ /**
+ * Gets the TIA (Test Impact Analysis) configuration.
+ */
+ public function tia(): Plugins\Tia\Configuration
+ {
+ return new Plugins\Tia\Configuration;
+ }
+
/**
* Proxies calls to the uses method.
*
diff --git a/src/Contracts/Restarter.php b/src/Contracts/Restarter.php
new file mode 100644
index 00000000..95324301
--- /dev/null
+++ b/src/Contracts/Restarter.php
@@ -0,0 +1,16 @@
+ $arguments
+ */
+ public function maybeRestart(string $projectRoot, array $arguments): void;
+}
diff --git a/src/Exceptions/BaselineFetchFailed.php b/src/Exceptions/BaselineFetchFailed.php
new file mode 100644
index 00000000..301345c2
--- /dev/null
+++ b/src/Exceptions/BaselineFetchFailed.php
@@ -0,0 +1,54 @@
+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/Exceptions/NoAffectedTestsFound.php b/src/Exceptions/NoAffectedTestsFound.php
new file mode 100644
index 00000000..162dacc2
--- /dev/null
+++ b/src/Exceptions/NoAffectedTestsFound.php
@@ -0,0 +1,32 @@
+writeln([
+ '',
+ ' INFO > No affected tests found.',
+ '',
+ ]);
+ }
+
+ public function exitCode(): int
+ {
+ return 0;
+ }
+}
diff --git a/src/Exceptions/TiaRequiresPestTests.php b/src/Exceptions/TiaRequiresPestTests.php
new file mode 100644
index 00000000..71d7615a
--- /dev/null
+++ b/src/Exceptions/TiaRequiresPestTests.php
@@ -0,0 +1,46 @@
+writeln([
+ '',
+ ' ERROR > Tia mode requires Pest tests.',
+ '',
+ sprintf(' Encountered PHPUnit class %s>', $this->className),
+ sprintf(' in %s>.', $this->file),
+ '',
+ ' Convert it to a Pest test, or run without Tia.',
+ '',
+ ]);
+ }
+
+ public function exitCode(): int
+ {
+ return 1;
+ }
+}
diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php
index 372653dd..5c8b394d 100644
--- a/src/Factories/TestCaseFactory.php
+++ b/src/Factories/TestCaseFactory.php
@@ -166,7 +166,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
- private static \$__filename = '$filename';
+ public static \$__filename = '$filename';
$methodsCode
}
diff --git a/src/Kernel.php b/src/Kernel.php
index 55b9e7a4..28232891 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/**
* @internal
*/
-final readonly class Kernel
+final class Kernel
{
+ /**
+ * Either the kernel is terminated or not.
+ */
+ private bool $terminated = false;
+
/**
* The Kernel bootstrappers.
*
@@ -36,6 +41,8 @@ final readonly class Kernel
*/
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
+ Bootstrappers\BootPhpUnitConfiguration::class,
+ Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class,
@@ -43,15 +50,22 @@ final readonly class Kernel
Bootstrappers\BootExcludeList::class,
];
+ /**
+ * The Kernel restarters — resolved and invoked from `bin/pest`
+ * before any other Pest class is touched, so the list is exposed
+ * on the Kernel rather than driven from `bin/pest` directly.
+ *
+ * @var array>
+ */
+ public const array RESTARTERS = [
+ Restarters\XdebugRestarter::class,
+ Restarters\PcovRestarter::class,
+ ];
+
/**
* Creates a new Kernel instance.
*/
- public function __construct(
- private Application $application,
- private OutputInterface $output,
- ) {
- //
- }
+ public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
/**
* Boots the Kernel.
@@ -112,9 +126,13 @@ final readonly class Kernel
$configuration = Registry::get();
$result = Facade::result();
- return CallsAddsOutput::execute(
+ $result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result),
);
+
+ $this->terminate();
+
+ return $result;
}
/**
@@ -122,6 +140,12 @@ final readonly class Kernel
*/
public function terminate(): void
{
+ if ($this->terminated) {
+ return;
+ }
+
+ $this->terminated = true;
+
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump);
diff --git a/src/Logging/Converter.php b/src/Logging/Converter.php
index e0b69bb0..1c8b38cc 100644
--- a/src/Logging/Converter.php
+++ b/src/Logging/Converter.php
@@ -12,7 +12,9 @@ use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
+use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
+use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
@@ -255,9 +257,11 @@ final readonly class Converter
$numberOfNotPassedTests = count(
array_unique(
array_map(
- function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
+ function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
- || $event instanceof AfterLastTestMethodErrored) {
+ || $event instanceof AfterLastTestMethodErrored
+ || $event instanceof BeforeFirstTestMethodFailed
+ || $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName();
}
diff --git a/src/Pest.php b/src/Pest.php
index 44ec0ca7..eb935c57 100644
--- a/src/Pest.php
+++ b/src/Pest.php
@@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
- return '5.0.0-rc.6';
+ return '5.0.0-rc.7';
}
function testDirectory(string $file = ''): string
diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php
new file mode 100644
index 00000000..6e6a83d8
--- /dev/null
+++ b/src/Plugins/Tia.php
@@ -0,0 +1,1780 @@
+
+ */
+ private const array VALUE_TAKING_FLAGS = [
+ '-c', '--configuration', '--bootstrap', '--cache-directory',
+ '--filter', '--group', '--exclude-group', '--covers', '--uses',
+ '--test-suffix', '--testsuite', '--exclude-testsuite',
+ '--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
+ '--include-path', '--whitelist',
+ '--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
+ '--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
+ '--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
+ '--coverage-filter', '--path-coverage',
+ '--repeat', '--retry-times', '--memory-limit', '--seed',
+ '--compact', '--ci-build-id', '--min',
+ ];
+
+ private bool $graphWritten = false;
+
+ private bool $replayRan = false;
+
+ private int $replayedCount = 0;
+
+ private int $affectedCount = 0;
+
+ private int $executedCount = 0;
+
+ /** @var array */
+ private array $cachedAssertionsByTestId = [];
+
+ private ?Graph $replayGraph = null;
+
+ private string $branch = 'main';
+
+ /** @var array */
+ private array $affectedFiles = [];
+
+ /** @var array{structural: array, environmental: array}|null */
+ private ?array $startFingerprint = null;
+
+ private bool $piggybackCoverage = false;
+
+ private bool $recordingActive = false;
+
+ private bool $forceRefetch = false;
+
+ private bool $baselineFetchAttemptedForDrift = false;
+
+ private bool $freshRebuild = false;
+
+ private bool $filteredMode = false;
+
+ private ?string $driftLabel = null;
+
+ private ?string $driftDetails = null;
+
+ private ?string $freshGraphReason = null;
+
+ public function __construct(
+ private readonly OutputInterface $output,
+ private readonly Recorder $recorder,
+ private readonly CoverageCollector $coverageCollector,
+ private readonly WatchPatterns $watchPatterns,
+ private readonly State $state,
+ private readonly BaselineSync $baselineSync,
+ ) {}
+
+ private function renderBadge(string $type, string $content): void
+ {
+ View::render('components.badge', ['type' => $type, 'content' => $content]);
+ }
+
+ private function renderChild(string $text): void
+ {
+ $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
+ {
+ $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);
+ }
+
+ /**
+ * @param array $arguments
+ */
+ public static function isEnabledForRun(array $arguments): bool
+ {
+ if (self::argumentPresent(self::NO_OPTION, $arguments)) {
+ return false;
+ }
+
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+ assert($watchPatterns instanceof WatchPatterns);
+
+ self::applyWatchPatternMarks($arguments, $watchPatterns);
+
+ if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) {
+ return true;
+ }
+
+ if (! $watchPatterns->isEnabled()) {
+ return false;
+ }
+
+ return ! ($watchPatterns->isLocally() && self::argumentPresent('--ci', $arguments));
+ }
+
+ /**
+ * @param array $arguments
+ */
+ private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
+ {
+ if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) {
+ $watchPatterns->markEnabled();
+ $watchPatterns->markLocally();
+ }
+
+ if (self::argumentPresent(self::BASELINED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_BASELINED)) {
+ $watchPatterns->markBaselined();
+ }
+ }
+
+ /**
+ * Mirrors {@see HandleArguments::hasArgument()} for
+ * use from static contexts — matches both `--flag` and `--flag=value`.
+ *
+ * @param array $arguments
+ */
+ private static function argumentPresent(string $argument, array $arguments): bool
+ {
+ foreach ($arguments as $arg) {
+ if ($arg === $argument) {
+ return true;
+ }
+
+ if (str_starts_with($arg, "$argument=")) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function envFlagEnabled(string $name): bool
+ {
+ return filter_var(getenv($name), FILTER_VALIDATE_BOOL);
+ }
+
+ public function getStatus(string $filename, string $testId): ?TestStatus
+ {
+ if (! $this->replayGraph instanceof Graph) {
+ return null;
+ }
+
+ $projectRoot = TestSuite::getInstance()->rootPath;
+ $real = @realpath($filename);
+ $rel = $real !== false
+ ? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR)))
+ : null;
+
+ if ($rel !== null && isset($this->affectedFiles[$rel])) {
+ $this->affectedCount++;
+ $this->executedCount++;
+
+ return null;
+ }
+
+ if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
+ $this->executedCount++;
+
+ return null;
+ }
+
+ $result = $this->replayGraph->getResult($this->branch, $testId);
+
+ if ($result instanceof TestStatus) {
+ if ($result->isFailure() || $result->isError()) {
+ $this->executedCount++;
+
+ return null;
+ }
+
+ $this->replayedCount++;
+ $assertions = $this->replayGraph->getAssertions($this->branch, $testId);
+ $this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
+ } else {
+ $this->executedCount++;
+ }
+
+ return $result;
+ }
+
+ public function getAssertionCount(string $testId): int
+ {
+ return $this->cachedAssertionsByTestId[$testId] ?? 0;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function handleArguments(array $arguments): array
+ {
+ if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) {
+ $this->output->writeln(Storage::tempDir(TestSuite::getInstance()->rootPath));
+
+ exit(0);
+ }
+
+ $isWorker = Parallel::isWorker();
+ $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
+ $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
+
+ /** @var WatchPatterns $watchPatterns */
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+ self::applyWatchPatternMarks($arguments, $watchPatterns);
+ $disabled = $this->hasArgument(self::NO_OPTION, $arguments);
+ $cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
+ $alwaysEnabled = $watchPatterns->isEnabled()
+ && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
+ $enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
+ $this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
+ && ! $this->hasExplicitPathArgument($arguments)
+ && ! $this->coverageReportActive();
+ $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
+ $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
+
+ $arguments = $this->popArgument(self::OPTION, $arguments);
+ $arguments = $this->popArgument(self::NO_OPTION, $arguments);
+ $arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
+ $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
+ $arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
+ $arguments = $this->popArgument(self::LOCALLY_OPTION, $arguments);
+ $arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
+
+ if ($disabled) {
+ $this->forceRefetch = false;
+ $this->filteredMode = false;
+ $this->freshRebuild = false;
+
+ return $arguments;
+ }
+
+ $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
+ $this->freshRebuild = $forceRebuild;
+
+ if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
+ return $arguments;
+ }
+
+ $this->piggybackCoverage = $isWorker
+ ? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1'
+ : $this->coverageReportActive();
+
+ $projectRoot = TestSuite::getInstance()->rootPath;
+
+ if ($isWorker) {
+ return $this->handleWorker($arguments, $projectRoot, $recordingGlobal, $replayingGlobal);
+ }
+
+ return $this->handleParent($arguments, $projectRoot, $forceRebuild);
+ }
+
+ public function terminate(): void
+ {
+ if ($this->graphWritten) {
+ return;
+ }
+
+ if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
+ $this->flushWorkerReplay();
+ }
+
+ $recorder = $this->recorder;
+
+ if (! $this->recordingActive && ! $recorder->isActive()) {
+ return;
+ }
+
+ $this->graphWritten = true;
+
+ $projectRoot = TestSuite::getInstance()->rootPath;
+ $perTest = $this->piggybackCoverage
+ ? $this->coverageCollector->perTestFiles()
+ : $recorder->perTestFiles();
+
+ if ($perTest === []) {
+ $recorder->reset();
+ $this->coverageCollector->reset();
+
+ return;
+ }
+
+ $perTestTables = $recorder->perTestTables();
+ $perTestInertia = $recorder->perTestInertiaComponents();
+ $perTestUsesDatabase = $recorder->perTestUsesDatabase();
+
+ if ($perTestUsesDatabase !== []) {
+ $perTestTables = $this->augmentDatabaseTestTables(
+ $perTestTables,
+ $perTestUsesDatabase,
+ $projectRoot,
+ );
+ }
+
+ if (Parallel::isWorker()) {
+ $this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
+ $recorder->reset();
+ $this->coverageCollector->reset();
+
+ return;
+ }
+
+ $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($currentFingerprint);
+ $graph->setRecordedAtSha($this->branch, $currentSha);
+ $graph->setLastRunTree(
+ $this->branch,
+ $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
+ );
+ $graph->replaceEdges($perTest);
+ $graph->replaceTestTables($perTestTables);
+ $graph->replaceTestInertiaComponents($perTestInertia);
+ $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
+
+ if ($this->freshRebuild) {
+ $graph->pruneMissingTests();
+ }
+
+ $this->seedResultsInto($graph);
+
+ if (! $this->saveGraph($graph)) {
+ $this->renderBadge('ERROR', 'Could not write the dependency graph.');
+ $recorder->reset();
+
+ return;
+ }
+
+ $recorder->reset();
+ $this->coverageCollector->reset();
+ }
+
+ public function addOutput(int $exitCode): int
+ {
+ if (Parallel::isWorker()) {
+ return $exitCode;
+ }
+
+ $this->reportMissingWorkerDrivers();
+
+ if (Parallel::isEnabled()) {
+ $this->mergeWorkerReplayPartials();
+ }
+
+ if ($this->replayRan) {
+ $this->bumpRecordedSha();
+ }
+
+ if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
+ $this->snapshotTestResults();
+
+ return $exitCode;
+ }
+
+ $projectRoot = TestSuite::getInstance()->rootPath;
+ $partialKeys = $this->collectWorkerEdgesPartials();
+
+ if ($partialKeys === []) {
+ if ($this->replayRan) {
+ $this->snapshotTestResults();
+ }
+
+ return $exitCode;
+ }
+
+ $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($currentFingerprint);
+ $graph->setRecordedAtSha($this->branch, $currentSha);
+ $graph->setLastRunTree(
+ $this->branch,
+ $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
+ );
+
+ [$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys);
+
+ if ($finalised === []) {
+ if ($this->replayRan) {
+ $this->snapshotTestResults();
+
+ return $exitCode;
+ }
+
+ $this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.');
+ $this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.');
+
+ return $exitCode;
+ }
+
+ $graph->replaceEdges($finalised);
+ $graph->replaceTestTables($finalisedTables);
+ $graph->replaceTestInertiaComponents($finalisedInertia);
+ $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
+
+ if ($this->freshRebuild) {
+ $graph->pruneMissingTests();
+ }
+
+ if (! $this->saveGraph($graph)) {
+ $this->renderBadge('ERROR', 'Could not write the dependency graph.');
+
+ return $exitCode;
+ }
+
+ $this->snapshotTestResults();
+
+ return $exitCode;
+ }
+
+ /**
+ * @param array{structural: array, environmental: array} $current
+ */
+ private function reconcileFingerprint(Graph $graph, array $current): ?Graph
+ {
+ $stored = $graph->fingerprint();
+
+ if (! Fingerprint::structuralMatches($stored, $current)) {
+ $drift = Fingerprint::structuralDrift($stored, $current);
+
+ $this->driftLabel = $this->formatStructuralDrift($drift);
+
+ if (in_array('composer_lock', $drift, true)) {
+ $branchSha = $graph->recordedAtSha($this->branch);
+ if ($branchSha !== null) {
+ $summary = $this->composerLockDelta(
+ TestSuite::getInstance()->rootPath,
+ $branchSha,
+ );
+ if ($summary !== '') {
+ $this->driftDetails = $summary;
+ }
+ }
+ }
+
+ $rebuilt = $this->tryRemoteBaselineForDrift($current);
+
+ if ($rebuilt instanceof Graph) {
+ return $this->reconcileFingerprint($rebuilt, $current);
+ }
+
+ $this->state->delete(self::KEY_GRAPH);
+ $this->state->delete(self::KEY_COVERAGE_CACHE);
+
+ return null;
+ }
+
+ $drift = Fingerprint::environmentalDrift($stored, $current);
+
+ if ($drift !== []) {
+ $this->renderBadge('WARN', sprintf(
+ 'Env differs from baseline (%s) — results dropped, edges reused.',
+ implode(', ', $drift),
+ ));
+
+ $graph->clearResults($this->branch);
+ $graph->setFingerprint($current);
+ $this->saveGraph($graph);
+ $this->state->delete(self::KEY_COVERAGE_CACHE);
+ }
+
+ return $graph;
+ }
+
+ /**
+ * @param array $arguments
+ * @return array
+ */
+ private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
+ {
+ $this->watchPatterns->useDefaults($projectRoot);
+ $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
+
+ $fingerprint = Fingerprint::compute($projectRoot);
+ $this->startFingerprint = $fingerprint;
+
+ if ($forceRebuild) {
+ Storage::purge($projectRoot);
+ }
+
+ $graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot);
+
+ if ($graph instanceof Graph) {
+ $graph = $this->reconcileFingerprint($graph, $fingerprint);
+ }
+
+ if ($graph instanceof Graph) {
+ $changedFiles = new ChangedFiles($projectRoot);
+ $branchSha = $graph->recordedAtSha($this->branch);
+
+ if ($branchSha !== null
+ && $changedFiles->since($branchSha) === null) {
+ $this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
+ $graph = null;
+ }
+ }
+
+ if (! $graph instanceof Graph
+ && ! $forceRebuild
+ && ! $this->baselineFetchAttemptedForDrift
+ && $this->watchPatterns->isBaselined()
+ && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
+ $this->baselineFetchAttemptedForDrift = true;
+ $graph = $this->loadGraph($projectRoot);
+ if ($graph instanceof Graph) {
+ $graph = $this->reconcileFingerprint($graph, $fingerprint);
+ }
+ }
+
+ if ($this->piggybackCoverage) {
+ $this->state->write(self::KEY_COVERAGE_MARKER, '');
+ }
+
+ if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
+ if ($graph instanceof Graph && $this->driftLabel === null) {
+ $this->freshGraphReason = 'recording coverage baseline';
+ }
+
+ return $this->enterRecordMode($arguments);
+ }
+
+ if ($graph instanceof Graph) {
+ return $this->enterReplayMode($graph, $projectRoot, $arguments);
+ }
+
+ return $this->enterRecordMode($arguments);
+ }
+
+ /**
+ * @param array $arguments
+ * @return array
+ */
+ private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
+ {
+ $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
+
+ if ($replayingGlobal) {
+ $this->installWorkerReplay($projectRoot);
+
+ if ($recordingGlobal) {
+ return $this->activateWorkerRecorderForReplay($arguments);
+ }
+
+ return $arguments;
+ }
+
+ if (! $recordingGlobal) {
+ return $arguments;
+ }
+
+ if ($this->piggybackCoverage) {
+ $this->recordingActive = true;
+
+ return $arguments;
+ }
+
+ $recorder = $this->recorder;
+
+ if (! $recorder->driverAvailable()) {
+ $this->state->write(
+ self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json',
+ '{}',
+ );
+
+ return $arguments;
+ }
+
+ $recorder->activate();
+ $this->recordingActive = true;
+
+ return $arguments;
+ }
+
+ private function installWorkerReplay(string $projectRoot): void
+ {
+ $graph = $this->loadGraph($projectRoot);
+
+ if (! $graph instanceof Graph) {
+ return;
+ }
+
+ $raw = $this->state->read(self::KEY_AFFECTED);
+
+ if ($raw === null) {
+ return;
+ }
+
+ $decoded = json_decode($raw, true);
+
+ if (! is_array($decoded)) {
+ return;
+ }
+
+ $affectedSet = [];
+
+ foreach ($decoded as $rel) {
+ if (is_string($rel)) {
+ $affectedSet[$rel] = true;
+ }
+ }
+
+ $this->replayGraph = $graph;
+ $this->affectedFiles = $affectedSet;
+
+ if ((string) Parallel::getGlobal(self::FILTERED_GLOBAL) === '1') {
+ TestSuite::getInstance()->tests->addTestCaseFilter(
+ new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
+ );
+ }
+ }
+
+ /**
+ * @param array $arguments
+ * @return array
+ */
+ private function activateWorkerRecorderForReplay(array $arguments): array
+ {
+ if ($this->piggybackCoverage) {
+ $this->recordingActive = true;
+
+ return $arguments;
+ }
+
+ $recorder = $this->recorder;
+
+ if (! $recorder->driverAvailable()) {
+ $this->state->write(
+ self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json',
+ '{}',
+ );
+
+ return $arguments;
+ }
+
+ $recorder->activate();
+ $this->recordingActive = true;
+
+ return $arguments;
+ }
+
+ /**
+ * @param array $arguments
+ * @return array
+ */
+ private function enterReplayMode(Graph $graph, string $projectRoot, array $arguments): array
+ {
+ $changedFiles = new ChangedFiles($projectRoot);
+
+ $branchSha = $graph->recordedAtSha($this->branch);
+ $changed = $changedFiles->since($branchSha) ?? [];
+
+ $changed = $changedFiles->filterUnchangedSinceLastRun(
+ $changed,
+ $graph->lastRunTree($this->branch),
+ );
+
+ $hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed);
+ $coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
+
+ if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
+ $this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.');
+ $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;
+ }
+
+ $affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
+ $rerunFromCache = [];
+
+ if ($this->filteredMode) {
+ $rerunFromCache = $graph->testFilesToRerun($this->branch);
+ }
+
+ $affected = array_values(array_unique([
+ ...$affectedFromChanges,
+ ...$rerunFromCache,
+ ]));
+
+ $this->reportAffectedSummary($changed, $affectedFromChanges, $rerunFromCache, $affected);
+
+ $affectedSet = array_fill_keys($affected, true);
+ $canRefreshReplayEdges = $affected !== [] && $coverageAvailable;
+
+ $this->replayRan = true;
+ $this->replayGraph = $graph;
+ $this->affectedFiles = $affectedSet;
+
+ $this->registerRecap();
+
+ if ($this->filteredMode) {
+ if ($affected === []) {
+ Panic::with(new NoAffectedTestsFound);
+ }
+
+ TestSuite::getInstance()->tests->addTestCaseFilter(
+ new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
+ );
+ }
+
+ if (! Parallel::isEnabled()) {
+ if ($canRefreshReplayEdges) {
+ $this->recorder->activate();
+ $this->recordingActive = true;
+ }
+
+ return $arguments;
+ }
+
+ if (! $this->persistAffectedSet($affected)) {
+ $this->renderBadge('ERROR', 'Could not persist affected set — running full suite.');
+
+ return $arguments;
+ }
+
+ $this->purgeWorkerPartials();
+
+ Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
+
+ if ($canRefreshReplayEdges) {
+ Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
+ }
+
+ if ($this->filteredMode) {
+ Parallel::setGlobal(self::FILTERED_GLOBAL, '1');
+ }
+
+ return $arguments;
+ }
+
+ /**
+ * @param array $changedFiles
+ * @param array $affectedFromChanges
+ * @param array $rerunFromCache
+ * @param array $affected
+ */
+ private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $rerunFromCache, array $affected): void
+ {
+ $this->output->writeln('');
+
+ if ($affected === []) {
+ $this->renderChild('Experimental TIA mode enabled.');
+
+ return;
+ }
+
+ $newReruns = $rerunFromCache === []
+ ? 0
+ : count(array_diff($rerunFromCache, $affectedFromChanges));
+
+ $reasons = [];
+ $singleReason = (int) ($affectedFromChanges !== []) + (int) ($newReruns > 0) === 1;
+
+ if ($affectedFromChanges !== []) {
+ $reasons[] = $singleReason
+ ? sprintf(
+ 'from %d changed file%s',
+ count($changedFiles),
+ count($changedFiles) === 1 ? '' : 's',
+ )
+ : sprintf(
+ '%d from %d changed file%s',
+ count($affectedFromChanges),
+ count($changedFiles),
+ count($changedFiles) === 1 ? '' : 's',
+ );
+ }
+
+ if ($newReruns > 0) {
+ $reasons[] = $singleReason
+ ? sprintf(
+ 'from %d previously unsuccessful test%s',
+ $newReruns,
+ $newReruns === 1 ? '' : 's',
+ )
+ : sprintf(
+ '%d from previously unsuccessful test%s',
+ $newReruns,
+ $newReruns === 1 ? '' : 's',
+ );
+ }
+
+ $this->renderChild(sprintf(
+ 'Experimental TIA mode enabled / %d affected test file%s%s.',
+ count($affected),
+ count($affected) === 1 ? '' : 's',
+ $reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
+ ));
+
+ $sorted = $affected;
+ sort($sorted);
+
+ $previewLimit = $this->output->isVerbose() ? count($sorted) : 10;
+ $preview = array_slice($sorted, 0, $previewLimit);
+
+ foreach ($preview as $file) {
+ $this->output->writeln(sprintf(' %s>', $file));
+ }
+
+ $remainder = count($sorted) - count($preview);
+
+ if ($remainder > 0) {
+ $this->output->writeln(sprintf(' … +%d more>', $remainder));
+ }
+ }
+
+ /**
+ * @param array $affected Project-relative paths.
+ */
+ private function persistAffectedSet(array $affected): bool
+ {
+ $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
+
+ if ($json === false) {
+ return false;
+ }
+
+ return $this->state->write(self::KEY_AFFECTED, $json);
+ }
+
+ /**
+ * @param array $arguments
+ * @return array
+ */
+ private function enterRecordMode(array $arguments): array
+ {
+ $recorder = $this->recorder;
+
+ if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) {
+ $this->emitCoverageDriverMissing();
+
+ return $arguments;
+ }
+
+ if (Parallel::isEnabled()) {
+ $this->purgeWorkerPartials();
+
+ Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
+
+ if ($this->piggybackCoverage) {
+ Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
+ }
+
+ $this->output->writeln('');
+ $this->renderFreshGraph();
+
+ return $arguments;
+ }
+
+ if ($this->piggybackCoverage) {
+ $this->recordingActive = true;
+
+ $this->output->writeln('');
+ $this->renderFreshGraph();
+
+ return $arguments;
+ }
+
+ $recorder->activate();
+ $this->recordingActive = true;
+
+ $this->renderChild('Running in TIA mode.');
+
+ return $arguments;
+ }
+
+ private function renderFreshGraph(): void
+ {
+ $headline = 'Experimental TIA mode enabled / fresh graph';
+
+ if ($this->driftLabel !== null) {
+ $headline .= sprintf(' (%s changed)', $this->driftLabel);
+ } elseif ($this->freshGraphReason !== null) {
+ $headline .= sprintf(' (%s)', $this->freshGraphReason);
+ } else {
+ $headline .= '.';
+ }
+
+ $this->renderChild($headline);
+
+ if ($this->driftDetails !== null) {
+ foreach (explode(', ', $this->driftDetails) as $detail) {
+ $this->output->writeln(sprintf(' %s>', $detail));
+ }
+ }
+ }
+
+ private function emitCoverageDriverMissing(): void
+ {
+ $this->output->writeln('');
+
+ $this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.');
+ }
+
+ /**
+ * @param array> $perTestFiles
+ * @param array> $perTestTables
+ * @param array> $perTestInertiaComponents
+ */
+ private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void
+ {
+ $json = json_encode([
+ 'files' => $perTestFiles,
+ 'tables' => $perTestTables,
+ 'inertia' => $perTestInertiaComponents,
+ ], JSON_UNESCAPED_SLASHES);
+
+ if ($json === false) {
+ return;
+ }
+
+ $this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json);
+ }
+
+ /**
+ * @return list
+ */
+ private function collectWorkerEdgesPartials(): array
+ {
+ return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
+ }
+
+ private function reportMissingWorkerDrivers(): void
+ {
+ $keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX);
+
+ if ($keys === []) {
+ return;
+ }
+
+ foreach ($keys as $key) {
+ $this->state->delete($key);
+ }
+
+ $this->renderBadge('WARN', sprintf(
+ '%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
+ count($keys),
+ ));
+ $this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
+ }
+
+ private function purgeWorkerPartials(): void
+ {
+ foreach ($this->collectWorkerEdgesPartials() as $key) {
+ $this->state->delete($key);
+ }
+ foreach ($this->collectWorkerReplayPartials() as $key) {
+ $this->state->delete($key);
+ }
+ }
+
+ private function flushWorkerReplay(): void
+ {
+ /** @var ResultCollector $collector */
+ $collector = Container::getInstance()->get(ResultCollector::class);
+
+ $results = $collector->all();
+
+ if ($results === [] && $this->replayedCount === 0 && $this->affectedCount === 0 && $this->executedCount === 0) {
+ return;
+ }
+
+ $json = json_encode([
+ 'results' => $results,
+ 'replayed' => $this->replayedCount,
+ 'affected' => $this->affectedCount,
+ 'executed' => $this->executedCount,
+ ], JSON_UNESCAPED_SLASHES);
+
+ if ($json === false) {
+ return;
+ }
+
+ $this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json);
+ }
+
+ /**
+ * @return list
+ */
+ private function collectWorkerReplayPartials(): array
+ {
+ return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
+ }
+
+ private function mergeWorkerReplayPartials(): void
+ {
+ /** @var ResultCollector $collector */
+ $collector = Container::getInstance()->get(ResultCollector::class);
+
+ foreach ($this->collectWorkerReplayPartials() as $key) {
+ $raw = $this->state->read($key);
+ $this->state->delete($key);
+
+ if ($raw === null) {
+ continue;
+ }
+
+ $decoded = json_decode($raw, true);
+
+ if (! is_array($decoded)) {
+ continue;
+ }
+
+ if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
+ $this->replayedCount += $decoded['replayed'];
+ }
+
+ if (isset($decoded['affected']) && is_int($decoded['affected'])) {
+ $this->affectedCount += $decoded['affected'];
+ }
+
+ if (isset($decoded['executed']) && is_int($decoded['executed'])) {
+ $this->executedCount += $decoded['executed'];
+ }
+
+ if (isset($decoded['results']) && is_array($decoded['results'])) {
+ $normalised = [];
+
+ /** @var mixed $result */
+ foreach ($decoded['results'] as $testId => $result) {
+ if (! is_string($testId)) {
+ continue;
+ }
+ if (! is_array($result)) {
+ continue;
+ }
+ $normalised[$testId] = [
+ 'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
+ 'message' => is_string($result['message'] ?? null) ? $result['message'] : '',
+ 'time' => is_float($result['time'] ?? null) || is_int($result['time'] ?? null) ? (float) $result['time'] : 0.0,
+ 'assertions' => is_int($result['assertions'] ?? null) ? $result['assertions'] : 0,
+ ];
+
+ if (isset($result['file']) && is_string($result['file'])) {
+ $normalised[$testId]['file'] = $result['file'];
+ }
+ }
+
+ if ($normalised !== []) {
+ $collector->merge($normalised);
+ }
+ }
+ }
+ }
+
+ private function workerToken(): string
+ {
+ $raw = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? null;
+
+ $token = is_scalar($raw) ? (string) $raw : (string) getmypid();
+ $token = preg_replace('/[^A-Za-z0-9_-]/', '', $token);
+
+ if ($token === null || $token === '') {
+ return (string) getmypid();
+ }
+
+ 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
+ */
+ private function readPartial(string $key): ?array
+ {
+ $raw = $this->state->read($key);
+
+ if ($raw === null) {
+ return null;
+ }
+
+ $data = json_decode($raw, true);
+
+ if (! is_array($data)) {
+ return null;
+ }
+
+ $filesSource = is_array($data['files'] ?? null) ? $data['files'] : [];
+ $tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : [];
+ $inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : [];
+
+ return [
+ 'files' => $this->cleanPartialSection($filesSource),
+ 'tables' => $this->cleanPartialSection($tablesSource),
+ 'inertia' => $this->cleanPartialSection($inertiaSource),
+ ];
+ }
+
+ /**
+ * @param array $section
+ * @return array>
+ */
+ private function cleanPartialSection(array $section): array
+ {
+ $out = [];
+
+ foreach ($section as $test => $items) {
+ if (! is_string($test)) {
+ continue;
+ }
+ if (! is_array($items)) {
+ continue;
+ }
+
+ $clean = [];
+
+ foreach ($items as $item) {
+ if (is_string($item)) {
+ $clean[] = $item;
+ }
+ }
+
+ $out[$test] = $clean;
+ }
+
+ return $out;
+ }
+
+ private function registerRecap(): void
+ {
+ DefaultPrinter::addRecap(function (): string {
+ if (Parallel::isEnabled() && ! Parallel::isWorker()) {
+ $this->mergeWorkerReplayPartials();
+ }
+
+ $fragments = [];
+
+ if ($this->affectedCount > 0) {
+ $fragments[] = $this->affectedCount.' affected';
+ }
+
+ $uncachedCount = max(0, $this->executedCount - $this->affectedCount);
+
+ if ($uncachedCount > 0) {
+ $fragments[] = $uncachedCount.' uncached';
+ }
+
+ if ($this->replayedCount > 0) {
+ $fragments[] = $this->replayedCount.' replayed';
+ }
+
+ return implode(', ', $fragments);
+ });
+ }
+
+ private function bumpRecordedSha(): void
+ {
+ $projectRoot = TestSuite::getInstance()->rootPath;
+
+ $graph = $this->loadGraph($projectRoot);
+
+ if (! $graph instanceof Graph) {
+ return;
+ }
+
+ $changedFiles = new ChangedFiles($projectRoot);
+ $currentSha = $changedFiles->currentSha();
+
+ if ($currentSha !== null) {
+ $graph->setRecordedAtSha($this->branch, $currentSha);
+ }
+
+ $workingTreeFiles = $changedFiles->since($currentSha) ?? [];
+ $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
+
+ $this->saveGraph($graph);
+ }
+
+ private function seedResultsInto(Graph $graph): void
+ {
+ /** @var ResultCollector $collector */
+ $collector = Container::getInstance()->get(ResultCollector::class);
+
+ $results = $collector->all();
+ $touchedFiles = [];
+
+ foreach ($results as $testId => $result) {
+ $file = $result['file'] ?? null;
+
+ if (is_string($file) && $file !== '') {
+ $touchedFiles[$file] = true;
+ }
+
+ $graph->setResult(
+ $this->branch,
+ $testId,
+ $result['status'],
+ $result['message'],
+ $result['time'],
+ $result['assertions'],
+ $file,
+ );
+ }
+
+ $graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
+
+ $collector->reset();
+ }
+
+ private function snapshotTestResults(): void
+ {
+ /** @var ResultCollector $collector */
+ $collector = Container::getInstance()->get(ResultCollector::class);
+
+ $results = $collector->all();
+
+ if ($results === []) {
+ return;
+ }
+
+ $projectRoot = TestSuite::getInstance()->rootPath;
+
+ $graph = $this->loadGraph($projectRoot);
+
+ if (! $graph instanceof Graph) {
+ return;
+ }
+
+ $touchedFiles = [];
+
+ foreach ($results as $testId => $result) {
+ $file = $result['file'] ?? null;
+
+ if ($file === null || str_contains($file, "eval()'d")) {
+ $file = $this->resolveFailedTestFile($testId);
+ }
+
+ if (is_string($file) && $file !== '') {
+ $touchedFiles[$file] = true;
+ }
+
+ $graph->setResult(
+ $this->branch,
+ $testId,
+ $result['status'],
+ $result['message'],
+ $result['time'],
+ $result['assertions'],
+ $file,
+ );
+ }
+
+ $graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
+
+ $this->saveGraph($graph);
+ $collector->reset();
+ }
+
+ private function resolveFailedTestFile(string $testId): ?string
+ {
+ $class = strstr($testId, '::', true);
+
+ if (! is_string($class) || $class === '' || ! class_exists($class)) {
+ return null;
+ }
+
+ assert(property_exists($class, '__filename') && is_string($class::$__filename));
+
+ $filename = $class::$__filename;
+
+ if ($filename !== '' && ! str_contains($filename, "eval()'d")) {
+ return $filename;
+ }
+
+ $current = new \ReflectionClass($class);
+
+ while ($current !== false) {
+ $file = $current->getFileName();
+
+ if ($file !== false && ! str_contains($file, "eval()'d")) {
+ return $file;
+ }
+
+ $current = $current->getParentClass();
+ }
+
+ return null;
+ }
+
+ private function coverageReportActive(): bool
+ {
+ $coverage = Container::getInstance()->get(Coverage::class);
+ assert($coverage instanceof Coverage);
+
+ return $coverage->coverage;
+ }
+
+ /**
+ * @param array $arguments
+ */
+ private function hasExplicitPathArgument(array $arguments): bool
+ {
+ $projectRoot = TestSuite::getInstance()->rootPath;
+ $testPaths = SourceScope::testPaths();
+
+ if ($testPaths === []) {
+ return false;
+ }
+
+ foreach ($arguments as $index => $arg) {
+ if ($arg === '') {
+ continue;
+ }
+ if (str_starts_with($arg, '-')) {
+ continue;
+ }
+ if ($index > 0) {
+ $previous = $arguments[$index - 1] ?? '';
+ if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
+ continue;
+ }
+ }
+
+ $candidate = $this->resolveArgumentPath($arg, $projectRoot);
+
+ if ($candidate === null) {
+ continue;
+ }
+
+ foreach ($testPaths as $testPath) {
+ if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private function resolveArgumentPath(string $arg, string $projectRoot): ?string
+ {
+ $candidates = [$arg, rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim($arg, DIRECTORY_SEPARATOR)];
+
+ foreach ($candidates as $candidate) {
+ if (! is_file($candidate) && ! is_dir($candidate)) {
+ continue;
+ }
+
+ $real = @realpath($candidate);
+
+ return rtrim($real === false ? $candidate : $real, '/\\');
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $changedFiles
+ */
+ private function hasProjectPhpSourceChanges(array $changedFiles): bool
+ {
+ foreach ($changedFiles as $rel) {
+ if (! str_ends_with($rel, '.php')) {
+ continue;
+ }
+
+ if (str_ends_with($rel, '.blade.php')) {
+ continue;
+ }
+ if (str_starts_with($rel, 'tests/')) {
+ continue;
+ }
+ if (str_starts_with($rel, 'vendor/')) {
+ continue;
+ }
+ if (str_starts_with($rel, 'storage/framework/')) {
+ continue;
+ }
+ if (str_starts_with($rel, 'bootstrap/cache/')) {
+ continue;
+ }
+
+ if (! is_file(TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.$rel)) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array{structural: array, environmental: array} $current
+ */
+ private function tryRemoteBaselineForDrift(array $current): ?Graph
+ {
+ if ($this->baselineFetchAttemptedForDrift) {
+ return null;
+ }
+
+ $projectRoot = TestSuite::getInstance()->rootPath;
+ $this->baselineFetchAttemptedForDrift = true;
+
+ if (! $this->watchPatterns->isBaselined()) {
+ return null;
+ }
+
+ if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
+ return null;
+ }
+
+ $fetched = $this->loadGraph($projectRoot);
+
+ if (! $fetched instanceof Graph) {
+ return null;
+ }
+
+ if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
+ $this->output->writeln(' However, baseline still drifts — discarding.>');
+
+ return null;
+ }
+
+ $this->renderBadge('SUCCESS', 'Fetched baseline matches — skipping local rebuild.');
+
+ return $fetched;
+ }
+
+ /**
+ * @param list $drift
+ */
+ private function formatStructuralDrift(array $drift): string
+ {
+ static $labels = [
+ 'composer_lock' => 'composer.lock',
+ 'composer_json' => 'composer.json',
+ 'phpunit_xml' => 'phpunit.xml',
+ 'phpunit_xml_dist' => 'phpunit.xml.dist',
+ 'vite_config' => 'vite.config',
+ 'package_json' => 'package.json',
+ 'package_lock' => 'Node lockfile',
+ 'js_config' => 'JS/TS config',
+ 'pest_factory' => 'Pest internals',
+ 'pest_method_factory' => 'Pest internals',
+ ];
+
+ $seen = [];
+ foreach ($drift as $key) {
+ $seen[$labels[$key] ?? $key] = true;
+ }
+
+ if ($seen === []) {
+ return 'unknown';
+ }
+
+ return implode(', ', array_keys($seen));
+ }
+
+ private function composerLockDelta(string $projectRoot, string $sha): string
+ {
+ $current = @file_get_contents($projectRoot.'/composer.lock');
+ if ($current === false) {
+ return '';
+ }
+
+ $process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
+ $process->setTimeout(5.0);
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ return '';
+ }
+
+ $oldVersions = $this->lockVersions($process->getOutput());
+ $newVersions = $this->lockVersions($current);
+
+ if ($oldVersions === [] && $newVersions === []) {
+ return '';
+ }
+
+ $changes = [];
+ foreach ($newVersions as $name => $version) {
+ if (! isset($oldVersions[$name])) {
+ $changes[] = '+ '.$name.' '.$version;
+ } elseif ($oldVersions[$name] !== $version) {
+ $changes[] = $name.' '.$oldVersions[$name].' → '.$version;
+ }
+ }
+ foreach ($oldVersions as $name => $version) {
+ if (! isset($newVersions[$name])) {
+ $changes[] = '− '.$name.' '.$version;
+ }
+ }
+
+ if ($changes === []) {
+ return '';
+ }
+
+ sort($changes);
+
+ $maxShown = 8;
+ if (count($changes) > $maxShown) {
+ $extra = count($changes) - $maxShown;
+ $changes = array_slice($changes, 0, $maxShown);
+ $changes[] = sprintf('… +%d more', $extra);
+ }
+
+ return implode(', ', $changes);
+ }
+
+ /**
+ * @param array> $perTestTables
+ * @param array $perTestUsesDatabase
+ * @return array>
+ */
+ private function augmentDatabaseTestTables(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array
+ {
+ $migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations';
+
+ if (! is_dir($migrationDir)) {
+ return $perTestTables;
+ }
+
+ $allTables = [];
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($migrationDir, \FilesystemIterator::SKIP_DOTS),
+ );
+
+ foreach ($iterator as $fileInfo) {
+ if (! $fileInfo->isFile()) {
+ continue;
+ }
+ if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) {
+ continue;
+ }
+
+ $content = @file_get_contents((string) $fileInfo->getPathname());
+
+ if ($content === false) {
+ continue;
+ }
+
+ foreach (TableExtractor::fromMigrationSource($content) as $table) {
+ $allTables[strtolower($table)] = true;
+ }
+ }
+
+ if ($allTables === []) {
+ return $perTestTables;
+ }
+
+ foreach (array_keys($perTestUsesDatabase) as $testFile) {
+ $existing = $perTestTables[$testFile] ?? [];
+ $merged = array_fill_keys($existing, true) + $allTables;
+ $names = array_keys($merged);
+ sort($names);
+ $perTestTables[$testFile] = $names;
+ }
+
+ return $perTestTables;
+ }
+
+ /**
+ * @return array package name → version
+ */
+ private function lockVersions(string $json): array
+ {
+ $data = json_decode($json, true);
+
+ if (! is_array($data)) {
+ return [];
+ }
+
+ $out = [];
+
+ foreach (['packages', 'packages-dev'] as $section) {
+ if (! isset($data[$section])) {
+ continue;
+ }
+ if (! is_array($data[$section])) {
+ continue;
+ }
+ foreach ($data[$section] as $package) {
+ if (! is_array($package)) {
+ continue;
+ }
+ $name = $package['name'] ?? null;
+ $version = $package['version'] ?? null;
+
+ if (is_string($name) && is_string($version)) {
+ $out[$name] = $version;
+ }
+ }
+ }
+
+ return $out;
+ }
+}
diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php
new file mode 100644
index 00000000..5b147883
--- /dev/null
+++ b/src/Plugins/Tia/BaselineSync.php
@@ -0,0 +1,621 @@
+ [
+ 'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
+ 'message' => 'network error (offline or DNS unreachable). Try again when connected.',
+ ],
+ 'gh-auth' => [
+ 'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
+ 'message' => 'authentication failed — run `gh auth login` and retry.',
+ ],
+ 'rate-limit' => [
+ 'pattern' => '/rate limit|too many requests|secondary rate limit/i',
+ 'message' => 'GitHub API rate limit hit — try again later.',
+ ],
+ 'not-found' => [
+ 'pattern' => '/404|not found|repository not found/i',
+ 'message' => 'workflow or artifact not found in repo.',
+ ],
+ 'forbidden' => [
+ 'pattern' => '/403|forbidden|access denied/i',
+ 'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
+ ],
+ ];
+
+ public function __construct(
+ private State $state,
+ private OutputInterface $output,
+ ) {}
+
+ private function renderBadge(string $type, string $content): void
+ {
+ View::render('components.badge', ['type' => $type, 'content' => $content]);
+ }
+
+ private function renderChild(string $text): void
+ {
+ $this->output->writeln(sprintf(' ─ %s>', $text));
+ }
+
+ public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
+ {
+ $repo = $this->detectGitHubRepo($projectRoot);
+
+ if ($repo === null) {
+ return false;
+ }
+
+ if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
+ $this->renderBadge('WARN', sprintf(
+ 'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
+ $this->formatDuration($remaining),
+ ));
+
+ return false;
+ }
+
+ $result = $this->download($repo, $projectRoot, $hasAnchor);
+ $payload = $result['payload'];
+ $failureKind = $result['failureKind'];
+
+ if ($payload === null) {
+ if ($failureKind === 'no-runs' || $failureKind === null) {
+ $this->startCooldown();
+ $this->emitPublishInstructions();
+ }
+
+ return false;
+ }
+
+ if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
+ return false;
+ }
+
+ if ($payload['coverage'] !== null) {
+ $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
+ }
+
+ $this->clearCooldown();
+
+ return true;
+ }
+
+ private function cooldownRemaining(): ?int
+ {
+ $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
+
+ if ($raw === null) {
+ return null;
+ }
+
+ $decoded = json_decode($raw, true);
+
+ if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
+ return null;
+ }
+
+ $remaining = $decoded['until'] - time();
+
+ return $remaining > 0 ? $remaining : null;
+ }
+
+ private function startCooldown(): void
+ {
+ $this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
+ 'until' => time() + self::FETCH_COOLDOWN_SECONDS,
+ ]));
+ }
+
+ private function clearCooldown(): void
+ {
+ $this->state->delete(Tia::KEY_FETCH_COOLDOWN);
+ }
+
+ private function formatDuration(int $seconds): string
+ {
+ if ($seconds >= 3600) {
+ return (int) round($seconds / 3600).'h';
+ }
+
+ if ($seconds >= 60) {
+ return (int) round($seconds / 60).'m';
+ }
+
+ return $seconds.'s';
+ }
+
+ private function emitPublishInstructions(): void
+ {
+ if ($this->isCi()) {
+ $this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
+
+ return;
+ }
+
+ $this->renderBadge('WARN', 'No baseline published yet — recording locally.');
+ $this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
+ }
+
+ private function isCi(): bool
+ {
+ return getenv('GITHUB_ACTIONS') === 'true'
+ || getenv('GITLAB_CI') === 'true'
+ || getenv('CIRCLECI') === 'true';
+ }
+
+ private function detectGitHubRepo(string $projectRoot): ?string
+ {
+ $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
+
+ if (! is_file($gitConfig)) {
+ return null;
+ }
+
+ $content = @file_get_contents($gitConfig);
+
+ if ($content === false) {
+ return null;
+ }
+
+ if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
+ return null;
+ }
+
+ $url = $match[1];
+
+ if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
+ return $m[1];
+ }
+
+ if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
+ return $m[1];
+ }
+
+ if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
+ return $m[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
+ */
+ private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
+ {
+ $this->validateGhDependencies($hasAnchor);
+
+ [$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
+
+ if ($listError !== null) {
+ $this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
+
+ $this->renderBadge('WARN', sprintf(
+ 'Failed to query baseline runs — %s',
+ $listError['message'],
+ ));
+
+ return ['payload' => null, 'failureKind' => $listError['kind']];
+ }
+
+ if ($runId === null) {
+ return ['payload' => null, 'failureKind' => 'no-runs'];
+ }
+
+ $runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
+
+ if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
+ @touch($runCacheDir);
+
+ $this->renderChild(sprintf(
+ 'Using cached baseline from %s (run %s).',
+ $repo,
+ $runId,
+ ));
+
+ return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
+ }
+
+ if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
+ return ['payload' => null, 'failureKind' => null];
+ }
+
+ $download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
+
+ if (! $download['success']) {
+ return ['payload' => null, 'failureKind' => $download['failureKind']];
+ }
+
+ $payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
+
+ $this->trimDownloadCache($projectRoot);
+
+ return ['payload' => $payload, 'failureKind' => null];
+ }
+
+ /**
+ * @param array{kind: string, message: string} $diagnosis
+ */
+ private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
+ {
+ if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
+ return;
+ }
+
+ Panic::with(new BaselineFetchFailed(
+ sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
+ 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
+ $hasAnchor,
+ ));
+ }
+
+ 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,
+ ));
+ }
+ }
+
+ /**
+ * @return array{success: bool, failureKind: ?string}
+ */
+ private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
+ {
+ $artifactSize = $this->artifactSize($repo, $runId);
+
+ $this->output->writeln('');
+ $this->renderChild($artifactSize !== null
+ ? sprintf(
+ 'Downloading TIA baseline (%s) from %s…',
+ $this->formatSize($artifactSize),
+ $repo,
+ )
+ : sprintf(
+ 'Downloading TIA baseline from %s…',
+ $repo,
+ ));
+
+ $process = new Process([
+ 'gh', 'run', 'download', $runId,
+ '-R', $repo,
+ '-n', self::ARTIFACT_NAME,
+ '-D', $runCacheDir,
+ ]);
+ $process->setTimeout(900.0);
+ $process->start();
+
+ $startedAt = microtime(true);
+ $tick = 0;
+
+ while ($process->isRunning()) {
+ $this->renderDownloadProgress($startedAt, $tick++);
+ usleep(120_000);
+ }
+
+ $process->wait();
+ $this->clearProgressLine();
+
+ if ($process->isSuccessful()) {
+ return ['success' => true, 'failureKind' => null];
+ }
+
+ $this->cleanup($runCacheDir);
+
+ $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
+
+ $this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
+
+ $this->renderBadge('WARN', sprintf(
+ 'Baseline download failed — %s',
+ $diagnosis['message'],
+ ));
+
+ return ['success' => false, 'failureKind' => $diagnosis['kind']];
+ }
+
+ /**
+ * @return array{graph: string, coverage: ?string, sizeOnDisk: int}
+ */
+ private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
+ {
+ $payload = $this->readArtifact($runCacheDir);
+
+ if ($payload === null) {
+ $this->cleanup($runCacheDir);
+
+ 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,
+ ));
+ }
+
+ return $payload;
+ }
+
+ private function artifactSize(string $repo, string $runId): ?int
+ {
+ $process = new Process([
+ 'gh', 'api',
+ sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
+ '--jq', sprintf(
+ '.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
+ self::ARTIFACT_NAME,
+ ),
+ ]);
+ $process->setTimeout(30.0);
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ return null;
+ }
+
+ $size = trim($process->getOutput());
+
+ return is_numeric($size) ? (int) $size : null;
+ }
+
+ private function renderDownloadProgress(float $startedAt, int $tick): void
+ {
+ static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
+
+ $elapsed = max(0.0, microtime(true) - $startedAt);
+ $frame = $frames[$tick % count($frames)];
+
+ $this->output->write(sprintf(
+ "\r\033[K %s %.1fs elapsed>",
+ $frame,
+ $elapsed,
+ ));
+ }
+
+ private function clearProgressLine(): void
+ {
+ $this->output->write("\r\033[K");
+ }
+
+ private function dirSize(string $dir): int
+ {
+ if (! is_dir($dir)) {
+ return 0;
+ }
+
+ $total = 0;
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
+ );
+
+ /** @var \SplFileInfo $entry */
+ foreach ($iterator as $entry) {
+ if ($entry->isFile()) {
+ $total += $entry->getSize();
+ }
+ }
+
+ return $total;
+ }
+
+ /**
+ * @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
+ */
+ private function readArtifact(string $dir): ?array
+ {
+ $graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
+ $coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
+
+ $graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
+
+ if ($graph === false) {
+ return null;
+ }
+
+ $coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
+
+ return [
+ 'graph' => $graph,
+ 'coverage' => $coverage === false ? null : $coverage,
+ 'sizeOnDisk' => $this->dirSize($dir),
+ ];
+ }
+
+ private function downloadCacheDir(string $projectRoot): string
+ {
+ return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
+ }
+
+ private function safeRunId(string $runId): string
+ {
+ $sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
+
+ return $sanitised === '' ? 'unknown' : $sanitised;
+ }
+
+ private function trimDownloadCache(string $projectRoot): void
+ {
+ $root = $this->downloadCacheDir($projectRoot);
+
+ if (! is_dir($root)) {
+ return;
+ }
+
+ $entries = @scandir($root);
+
+ if ($entries === false) {
+ return;
+ }
+
+ $candidates = [];
+
+ foreach ($entries as $entry) {
+ if (in_array($entry, ['.', '..'], true)) {
+ continue;
+ }
+
+ $path = $root.DIRECTORY_SEPARATOR.$entry;
+
+ if (! is_dir($path)) {
+ continue;
+ }
+
+ $mtime = @filemtime($path);
+ $candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
+ }
+
+ if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
+ return;
+ }
+
+ usort(
+ $candidates,
+ static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
+ );
+
+ foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
+ $this->cleanup($stale['path']);
+ }
+ }
+
+ /**
+ * @return array{0: ?string, 1: ?array{kind: string, message: string}}
+ */
+ private function latestSuccessfulRunIdWithError(string $repo): array
+ {
+ $process = new Process([
+ 'gh', 'run', 'list',
+ '-R', $repo,
+ '--workflow', self::WORKFLOW_FILE,
+ '--status', 'success',
+ '--limit', '1',
+ '--json', 'databaseId',
+ '--jq', '.[0].databaseId // empty',
+ ]);
+ $process->setTimeout(30.0);
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
+ }
+
+ $runId = trim($process->getOutput());
+
+ return [$runId === '' ? null : $runId, null];
+ }
+
+ private function ghAuthenticated(): bool
+ {
+ $process = new Process(['gh', 'auth', 'status']);
+ $process->setTimeout(10.0);
+ $process->run();
+
+ return $process->isSuccessful();
+ }
+
+ /**
+ * @return array{kind: string, message: string}
+ */
+ private function classifyGhError(string $output): array
+ {
+ $output = trim($output);
+
+ if ($output === '') {
+ return ['kind' => 'unknown', 'message' => 'unknown error'];
+ }
+
+ foreach (self::DIAGNOSES as $kind => $diagnosis) {
+ if (preg_match($diagnosis['pattern'], $output) === 1) {
+ return ['kind' => $kind, 'message' => $diagnosis['message']];
+ }
+ }
+
+ return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
+ }
+
+ private function commandExists(string $cmd): bool
+ {
+ $process = new Process(['which', $cmd]);
+ $process->run();
+
+ return $process->isSuccessful();
+ }
+
+ private function cleanup(string $dir): void
+ {
+ if (! is_dir($dir)) {
+ return;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+
+ /** @var \SplFileInfo $entry */
+ foreach ($iterator as $entry) {
+ if ($entry->isDir()) {
+ @rmdir($entry->getPathname());
+ } else {
+ @unlink($entry->getPathname());
+ }
+ }
+
+ @rmdir($dir);
+ }
+
+ private function formatSize(int $bytes): string
+ {
+ if ($bytes >= 1024 * 1024) {
+ return sprintf('%.1f MB', $bytes / 1024 / 1024);
+ }
+
+ if ($bytes >= 1024) {
+ return sprintf('%.1f KB', $bytes / 1024);
+ }
+
+ return $bytes.' B';
+ }
+}
diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php
new file mode 100644
index 00000000..b26ecb43
--- /dev/null
+++ b/src/Plugins/Tia/Bootstrapper.php
@@ -0,0 +1,28 @@
+container->get(TestSuite::class);
+ assert($testSuite instanceof TestSuite);
+
+ $tempDir = Storage::tempDir($testSuite->rootPath);
+
+ $this->container->add(State::class, new FileState($tempDir));
+ }
+}
diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php
new file mode 100644
index 00000000..9ffb680e
--- /dev/null
+++ b/src/Plugins/Tia/ChangedFiles.php
@@ -0,0 +1,326 @@
+ $files project-relative paths.
+ * @param array $lastRunTree path → content hash from last run.
+ * @return array
+ */
+ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
+ {
+ if ($lastRunTree === []) {
+ return $files;
+ }
+
+ $candidates = array_fill_keys($files, true);
+
+ foreach (array_keys($lastRunTree) as $snapshotted) {
+ $candidates[$snapshotted] = true;
+ }
+
+ $remaining = [];
+
+ foreach (array_keys($candidates) as $file) {
+ $snapshot = $lastRunTree[$file] ?? null;
+ $current = $this->currentHash($file);
+
+ if ($snapshot === null || $current === null || $current !== $snapshot) {
+ $remaining[] = $file;
+ }
+ }
+
+ return $remaining;
+ }
+
+ private function currentHash(string $relativePath): ?string
+ {
+ $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
+
+ if (! is_file($absolute)) {
+ return null;
+ }
+
+ $hash = ContentHash::of($absolute);
+
+ return $hash === false ? null : $hash;
+ }
+
+ /**
+ * @param array $files
+ * @return array path → xxh128 content hash
+ */
+ public function snapshotTree(array $files): array
+ {
+ $out = [];
+
+ foreach ($files as $file) {
+ $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
+
+ if (! is_file($absolute)) {
+ $out[$file] = '';
+
+ continue;
+ }
+
+ $hash = ContentHash::of($absolute);
+
+ if ($hash !== false) {
+ $out[$file] = $hash;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return array|null `null` when git is unavailable, or when
+ */
+ public function since(?string $sha): ?array
+ {
+ $files = [];
+
+ if ($sha !== null && $sha !== '') {
+ if (! $this->shaIsReachable($sha)) {
+ return null;
+ }
+
+ $files = array_merge($files, $this->diffSinceSha($sha));
+ }
+
+ $files = array_merge($files, $this->workingTreeChanges());
+
+ $unique = [];
+
+ foreach ($files as $file) {
+ if ($file === '') {
+ continue;
+ }
+ $unique[$file] = true;
+ }
+
+ $candidates = array_keys($this->filterIgnored($unique));
+
+ if ($sha !== null && $sha !== '') {
+ return $this->filterBehaviourallyUnchanged($candidates, $sha);
+ }
+
+ return $candidates;
+ }
+
+ /**
+ * @param array $files
+ * @return array
+ */
+ private function filterBehaviourallyUnchanged(array $files, string $sha): array
+ {
+ $remaining = [];
+
+ foreach ($files as $file) {
+ $currentHash = $this->currentHash($file);
+
+ if ($currentHash === null) {
+ $remaining[] = $file;
+
+ continue;
+ }
+
+ $baselineContent = $this->contentAtSha($sha, $file);
+
+ if ($baselineContent === null) {
+ $remaining[] = $file;
+
+ continue;
+ }
+
+ if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
+ $remaining[] = $file;
+ }
+ }
+
+ return $remaining;
+ }
+
+ private function contentAtSha(string $sha, string $path): ?string
+ {
+ $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
+ $process->setTimeout(5.0);
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ return null;
+ }
+
+ return $process->getOutput();
+ }
+
+ /**
+ * @param array $candidates
+ * @return array
+ */
+ private function filterIgnored(array $candidates): array
+ {
+ if ($candidates === []) {
+ return $candidates;
+ }
+
+ $process = new Process(
+ ['git', 'check-ignore', '--no-index', '-z', '--stdin'],
+ $this->projectRoot,
+ );
+ $process->setTimeout(5.0);
+ $process->setInput(implode("\x00", array_keys($candidates)));
+ $process->run();
+
+ $exitCode = $process->getExitCode();
+
+ if ($exitCode !== 0 && $exitCode !== 1) {
+ throw new MissingDependency('Tia mode', 'git');
+ }
+
+ $output = $process->getOutput();
+
+ if ($output === '') {
+ return $candidates;
+ }
+
+ foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
+ if ($ignored !== '') {
+ unset($candidates[$ignored]);
+ }
+ }
+
+ return $candidates;
+ }
+
+ public function currentBranch(): ?string
+ {
+ $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ throw new MissingDependency('Tia mode', 'git');
+ }
+
+ $branch = trim($process->getOutput());
+
+ return $branch === '' || $branch === 'HEAD' ? null : $branch;
+ }
+
+ private function shaIsReachable(string $sha): bool
+ {
+ $process = new Process(
+ ['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
+ $this->projectRoot,
+ );
+ $process->run();
+
+ return $process->getExitCode() === 0;
+ }
+
+ /**
+ * @return array
+ */
+ private function diffSinceSha(string $sha): array
+ {
+ $process = new Process(
+ ['git', 'diff', '--name-only', $sha.'..HEAD'],
+ $this->projectRoot,
+ );
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ throw new MissingDependency('Tia mode', 'git');
+ }
+
+ return $this->splitLines($process->getOutput());
+ }
+
+ /**
+ * @return array
+ */
+ private function workingTreeChanges(): array
+ {
+ $process = new Process(
+ ['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
+ $this->projectRoot,
+ );
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ throw new MissingDependency('Tia mode', 'git');
+ }
+
+ $output = $process->getOutput();
+
+ if ($output === '') {
+ return [];
+ }
+
+ $records = explode("\x00", rtrim($output, "\x00"));
+ $files = [];
+ $count = count($records);
+
+ for ($i = 0; $i < $count; $i++) {
+ $record = $records[$i];
+
+ if (strlen($record) < 4) {
+ continue;
+ }
+
+ $status = substr($record, 0, 2);
+ $path = substr($record, 3);
+
+ if ($status[0] === 'R' || $status[0] === 'C') {
+ $files[] = $path;
+
+ if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
+ $files[] = $records[$i + 1];
+ $i++;
+ }
+
+ continue;
+ }
+
+ $files[] = $path;
+ }
+
+ return $files;
+ }
+
+ public function currentSha(): ?string
+ {
+ $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ throw new MissingDependency('Tia mode', 'git');
+ }
+
+ $sha = trim($process->getOutput());
+
+ return $sha === '' ? null : $sha;
+ }
+
+ /**
+ * @return array
+ */
+ private function splitLines(string $output): array
+ {
+ $lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
+
+ return $lines === false ? [] : $lines;
+ }
+}
diff --git a/src/Plugins/Tia/Collectors.php b/src/Plugins/Tia/Collectors.php
new file mode 100644
index 00000000..c182f7b7
--- /dev/null
+++ b/src/Plugins/Tia/Collectors.php
@@ -0,0 +1,28 @@
+ */
+ private const array COLLECTORS = [
+ BladeEdges::class,
+ TableTracker::class,
+ InertiaEdges::class,
+ ];
+
+ public static function armAll(Recorder $recorder): void
+ {
+ foreach (self::COLLECTORS as $collector) {
+ $collector::arm($recorder);
+ }
+ }
+}
diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php
new file mode 100644
index 00000000..61789801
--- /dev/null
+++ b/src/Plugins/Tia/Configuration.php
@@ -0,0 +1,75 @@
+get(WatchPatterns::class);
+ $watchPatterns->markEnabled();
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function locally(): self
+ {
+ /** @var WatchPatterns $watchPatterns */
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+ $watchPatterns->markEnabled();
+ $watchPatterns->markLocally();
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function filtered(): self
+ {
+ /** @var WatchPatterns $watchPatterns */
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+ $watchPatterns->markFiltered();
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function baselined(): self
+ {
+ /** @var WatchPatterns $watchPatterns */
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+ $watchPatterns->markBaselined();
+
+ return $this;
+ }
+
+ /**
+ * @param array $patterns glob → project-relative test dir
+ * @return $this
+ */
+ public function watch(array $patterns): self
+ {
+ /** @var WatchPatterns $watchPatterns */
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+ $watchPatterns->add($patterns);
+
+ return $this;
+ }
+}
diff --git a/src/Plugins/Tia/ContentHash.php b/src/Plugins/Tia/ContentHash.php
new file mode 100644
index 00000000..f8538bb5
--- /dev/null
+++ b/src/Plugins/Tia/ContentHash.php
@@ -0,0 +1,90 @@
+
+ */
+ public function keysWithPrefix(string $prefix): array;
+}
diff --git a/src/Plugins/Tia/Contracts/WatchDefault.php b/src/Plugins/Tia/Contracts/WatchDefault.php
new file mode 100644
index 00000000..feb82de4
--- /dev/null
+++ b/src/Plugins/Tia/Contracts/WatchDefault.php
@@ -0,0 +1,18 @@
+> pattern → list of project-relative test dirs
+ */
+ public function defaults(string $projectRoot, string $testPath): array;
+}
diff --git a/src/Plugins/Tia/CoverageCollector.php b/src/Plugins/Tia/CoverageCollector.php
new file mode 100644
index 00000000..1aaf34cb
--- /dev/null
+++ b/src/Plugins/Tia/CoverageCollector.php
@@ -0,0 +1,110 @@
+
+ */
+ private array $classFileCache = [];
+
+ /**
+ * @return array>
+ */
+ public function perTestFiles(): array
+ {
+ if (! PhpUnitCodeCoverage::instance()->isActive()) {
+ return [];
+ }
+
+ try {
+ $lineCoverage = PhpUnitCodeCoverage::instance()
+ ->codeCoverage()
+ ->getData()
+ ->lineCoverage();
+ } catch (Throwable) {
+ return [];
+ }
+
+ /** @var array> $edges */
+ $edges = [];
+
+ foreach ($lineCoverage as $sourceFile => $lines) {
+ $testIds = [];
+
+ foreach ($lines as $hits) {
+ if ($hits === null) {
+ continue;
+ }
+
+ foreach ($hits as $id) {
+ $testIds[$id] = true;
+ }
+ }
+
+ foreach (array_keys($testIds) as $testId) {
+ $testFile = $this->testIdToFile($testId);
+
+ if ($testFile === null) {
+ continue;
+ }
+
+ $edges[$testFile][$sourceFile] = true;
+ }
+ }
+
+ $out = [];
+
+ foreach ($edges as $testFile => $sources) {
+ $out[$testFile] = array_keys($sources);
+ }
+
+ return $out;
+ }
+
+ public function reset(): void
+ {
+ $this->classFileCache = [];
+ }
+
+ private function testIdToFile(string $testId): ?string
+ {
+ $hash = strpos($testId, '#');
+ $identifier = $hash === false ? $testId : substr($testId, 0, $hash);
+
+ if (! str_contains($identifier, '::')) {
+ return null;
+ }
+
+ [$className] = explode('::', $identifier, 2);
+
+ if (array_key_exists($className, $this->classFileCache)) {
+ return $this->classFileCache[$className];
+ }
+
+ $file = $this->resolveClassFile($className);
+ $this->classFileCache[$className] = $file;
+
+ return $file;
+ }
+
+ private function resolveClassFile(string $className): ?string
+ {
+ if (! class_exists($className, false)) {
+ return null;
+ }
+
+ assert(property_exists($className, '__filename') && is_string($className::$__filename));
+
+ return $className::$__filename;
+ }
+}
diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php
new file mode 100644
index 00000000..f64f640e
--- /dev/null
+++ b/src/Plugins/Tia/CoverageMerger.php
@@ -0,0 +1,177 @@
+exists(Tia::KEY_COVERAGE_MARKER)) {
+ return;
+ }
+
+ $state->delete(Tia::KEY_COVERAGE_MARKER);
+
+ $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
+
+ if ($cachedBytes === null) {
+ $current = self::requireCoverage($reportPath);
+
+ if ($current instanceof CodeCoverage) {
+ self::primeUncoveredFiles($current);
+ $state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
+ }
+
+ return;
+ }
+
+ $decoded = self::decompress($cachedBytes);
+
+ if ($decoded === null) {
+ $state->delete(Tia::KEY_COVERAGE_CACHE);
+
+ return;
+ }
+
+ $cached = self::unserializeCoverage($decoded);
+ $current = self::requireCoverage($reportPath);
+
+ if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
+ return;
+ }
+
+ self::primeUncoveredFiles($cached);
+ self::primeUncoveredFiles($current);
+
+ self::stripCurrentTestsFromCached($cached, $current);
+
+ $cached->merge($current);
+
+ $serialised = serialize($cached);
+
+ @file_put_contents(
+ $reportPath,
+ 'write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
+ }
+
+ private static function primeUncoveredFiles(CodeCoverage $coverage): void
+ {
+ $coverage->getData(false);
+ }
+
+ private static function compress(string $bytes): string
+ {
+ $compressed = @gzencode($bytes);
+
+ return $compressed === false ? $bytes : $compressed;
+ }
+
+ private static function decompress(string $bytes): ?string
+ {
+ $decoded = @gzdecode($bytes);
+
+ return $decoded === false ? null : $decoded;
+ }
+
+ private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
+ {
+ $currentIds = self::collectTestIds($current);
+
+ if ($currentIds === []) {
+ return;
+ }
+
+ $cachedData = $cached->getData();
+ $lineCoverage = $cachedData->lineCoverage();
+
+ foreach ($lineCoverage as $file => $lines) {
+ foreach ($lines as $line => $ids) {
+ if ($ids === null) {
+ continue;
+ }
+ if ($ids === []) {
+ continue;
+ }
+ $filtered = array_values(array_diff($ids, $currentIds));
+
+ if ($filtered !== $ids) {
+ $lineCoverage[$file][$line] = $filtered;
+ }
+ }
+ }
+
+ $cachedData->setLineCoverage($lineCoverage);
+ }
+
+ /**
+ * @return array
+ */
+ private static function collectTestIds(CodeCoverage $coverage): array
+ {
+ $ids = [];
+
+ foreach ($coverage->getData()->lineCoverage() as $lines) {
+ foreach ($lines as $hits) {
+ if ($hits === null) {
+ continue;
+ }
+
+ foreach ($hits as $id) {
+ $ids[$id] = true;
+ }
+ }
+ }
+
+ return array_keys($ids);
+ }
+
+ private static function state(): State
+ {
+ $state = Container::getInstance()->get(State::class);
+ assert($state instanceof State);
+
+ return $state;
+ }
+
+ 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 {
+ $value = @unserialize($bytes);
+ } catch (Throwable) {
+ return null;
+ }
+
+ return $value instanceof CodeCoverage ? $value : null;
+ }
+}
diff --git a/src/Plugins/Tia/Edges/BladeEdges.php b/src/Plugins/Tia/Edges/BladeEdges.php
new file mode 100644
index 00000000..1d993145
--- /dev/null
+++ b/src/Plugins/Tia/Edges/BladeEdges.php
@@ -0,0 +1,62 @@
+isActive()) {
+ return;
+ }
+
+ $containerClass = self::CONTAINER_CLASS;
+
+ if (! class_exists($containerClass)) {
+ return;
+ }
+
+ /** @var object $app */
+ $app = $containerClass::getInstance();
+
+ if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
+ return;
+ }
+
+ if ($app->bound(self::MARKER) || ! $app->bound('view')) {
+ return;
+ }
+
+ $app->instance(self::MARKER, true);
+
+ $factory = $app->make('view');
+
+ if (! is_object($factory) || ! method_exists($factory, 'composer')) {
+ return;
+ }
+
+ $factory->composer('*', static function (object $view) use ($recorder): void {
+ if (! method_exists($view, 'getPath')) {
+ return;
+ }
+
+ /** @var mixed $path */
+ $path = $view->getPath();
+
+ if (is_string($path) && $path !== '') {
+ $recorder->linkSource($path);
+ }
+ });
+ }
+}
diff --git a/src/Plugins/Tia/Edges/InertiaEdges.php b/src/Plugins/Tia/Edges/InertiaEdges.php
new file mode 100644
index 00000000..038c1d41
--- /dev/null
+++ b/src/Plugins/Tia/Edges/InertiaEdges.php
@@ -0,0 +1,131 @@
+isActive()) {
+ return;
+ }
+
+ $containerClass = self::CONTAINER_CLASS;
+
+ if (! class_exists($containerClass)) {
+ return;
+ }
+
+ /** @var object $app */
+ $app = $containerClass::getInstance();
+
+ if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
+ return;
+ }
+
+ if ($app->bound(self::MARKER) || ! $app->bound('events')) {
+ return;
+ }
+
+ $app->instance(self::MARKER, true);
+
+ /** @var object $events */
+ $events = $app->make('events');
+
+ if (! method_exists($events, 'listen')) {
+ return;
+ }
+
+ $events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
+ if (! property_exists($event, 'response') || ! is_object($event->response)) {
+ return;
+ }
+
+ $component = self::extractComponent($event->response);
+
+ if ($component !== null) {
+ $recorder->linkInertiaComponent($component);
+ }
+ });
+ }
+
+ private static function extractComponent(object $response): ?string
+ {
+ $content = self::readContent($response);
+
+ if ($content === null) {
+ return null;
+ }
+
+ if (self::isInertiaJsonResponse($response)) {
+ return self::componentFromJson($content);
+ }
+
+ if (str_contains($content, 'type="application/json"')
+ && preg_match('##s', $content, $match) === 1) {
+ $component = self::componentFromJson(html_entity_decode($match[1]));
+
+ if ($component !== null) {
+ return $component;
+ }
+ }
+
+ if (str_contains($content, 'data-page=')
+ && preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
+ return self::componentFromJson(html_entity_decode($match[1]));
+ }
+
+ return null;
+ }
+
+ private static function isInertiaJsonResponse(object $response): bool
+ {
+ if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
+ return false;
+ }
+
+ $headers = $response->headers;
+
+ return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
+ }
+
+ private static function componentFromJson(string $json): ?string
+ {
+ /** @var mixed $decoded */
+ $decoded = json_decode($json, true);
+
+ if (is_array($decoded)
+ && isset($decoded['component'])
+ && is_string($decoded['component'])
+ && $decoded['component'] !== '') {
+ return $decoded['component'];
+ }
+
+ return null;
+ }
+
+ private static function readContent(object $response): ?string
+ {
+ if (! method_exists($response, 'getContent')) {
+ return null;
+ }
+
+ /** @var mixed $content */
+ $content = $response->getContent();
+
+ return is_string($content) ? $content : null;
+ }
+}
diff --git a/src/Plugins/Tia/Enums/ReplayType.php b/src/Plugins/Tia/Enums/ReplayType.php
new file mode 100644
index 00000000..6b669cc6
--- /dev/null
+++ b/src/Plugins/Tia/Enums/ReplayType.php
@@ -0,0 +1,35 @@
+isSuccess() => self::Pass,
+ $status->isRisky() => self::Risky,
+ $status->isSkipped() => self::Skipped,
+ $status->isIncomplete() => self::Incomplete,
+ default => self::Failure,
+ };
+ }
+}
diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php
new file mode 100644
index 00000000..91dc7892
--- /dev/null
+++ b/src/Plugins/Tia/FileState.php
@@ -0,0 +1,130 @@
+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;
+ }
+
+ 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;
+ }
+
+ public function pathFor(string $key): string
+ {
+ return $this->rootDir.DIRECTORY_SEPARATOR.$key;
+ }
+
+ private function resolvedRoot(): ?string
+ {
+ if ($this->resolvedRoot !== null) {
+ return $this->resolvedRoot;
+ }
+
+ $resolved = @realpath($this->rootDir);
+
+ if ($resolved === false) {
+ return null;
+ }
+
+ return $this->resolvedRoot = $resolved;
+ }
+
+ 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/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php
new file mode 100644
index 00000000..fc4c4f45
--- /dev/null
+++ b/src/Plugins/Tia/Fingerprint.php
@@ -0,0 +1,282 @@
+,
+ * environmental: array,
+ * }
+ */
+ public static function compute(string $projectRoot): array
+ {
+ return [
+ 'structural' => [
+ 'schema' => self::SCHEMA_VERSION,
+ 'composer_lock' => self::composerLockHash($projectRoot),
+ 'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
+ 'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
+ // 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
+ // 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
+ 'vite_config' => self::viteConfigHash($projectRoot),
+ // 'package_json' => self::packageJsonHash($projectRoot),
+ 'package_lock' => self::packageLockHash($projectRoot),
+ 'js_config' => self::jsConfigHash($projectRoot),
+ // 'composer_json' => self::composerJsonHash($projectRoot),
+ ],
+ 'environmental' => [
+ 'php_minor' => PHP_MAJOR_VERSION,
+
+ // 'extensions' => self::extensionsFingerprint($projectRoot),
+ // 'env_files' => self::envFilesHash($projectRoot),
+ ],
+ ];
+ }
+
+ /**
+ * @param array $a
+ * @param array $b
+ */
+ public static function structuralMatches(array $a, array $b): bool
+ {
+ $aStructural = self::structuralOnly($a);
+ $bStructural = self::structuralOnly($b);
+
+ ksort($aStructural);
+ ksort($bStructural);
+
+ return $aStructural === $bStructural;
+ }
+
+ /**
+ * @param array $stored
+ * @param array $current
+ * @return list
+ */
+ public static function structuralDrift(array $stored, array $current): array
+ {
+ return self::detectDrift(
+ self::structuralOnly($stored),
+ self::structuralOnly($current),
+ 'schema',
+ );
+ }
+
+ /**
+ * @param array $stored
+ * @param array $current
+ * @return list
+ */
+ public static function environmentalDrift(array $stored, array $current): array
+ {
+ 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;
+ }
+ }
+
+ return array_values(array_unique($drifts));
+ }
+
+ /**
+ * @param array $fingerprint
+ * @return array
+ */
+ private static function structuralOnly(array $fingerprint): array
+ {
+ return self::bucket($fingerprint, 'structural');
+ }
+
+ /**
+ * @param array $fingerprint
+ * @return array
+ */
+ private static function environmentalOnly(array $fingerprint): array
+ {
+ return self::bucket($fingerprint, 'environmental');
+ }
+
+ /**
+ * @param array $fingerprint
+ * @return array
+ */
+ private static function bucket(array $fingerprint, string $key): array
+ {
+ $raw = $fingerprint[$key] ?? null;
+
+ if (! is_array($raw)) {
+ return [];
+ }
+
+ $normalised = [];
+
+ foreach ($raw as $k => $v) {
+ if (is_string($k)) {
+ $normalised[$k] = $v;
+ }
+ }
+
+ return $normalised;
+ }
+
+ private static function viteConfigHash(string $projectRoot): ?string
+ {
+ $parts = [];
+
+ foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
+ if (! self::isTrackedByGit($projectRoot, $name)) {
+ continue;
+ }
+
+ $hash = self::contentHashOrNull($projectRoot.'/'.$name);
+
+ if ($hash !== null) {
+ $parts[] = $name.':'.$hash;
+ }
+ }
+
+ return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
+ }
+
+ private static function jsConfigHash(string $projectRoot): ?string
+ {
+ $parts = [];
+
+ foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
+ if (! self::isTrackedByGit($projectRoot, $name)) {
+ continue;
+ }
+
+ $hash = self::hashIfExists($projectRoot.'/'.$name);
+
+ if ($hash !== null) {
+ $parts[] = $name.':'.$hash;
+ }
+ }
+
+ return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
+ }
+
+ private static function composerLockHash(string $projectRoot): ?string
+ {
+ return self::trackedHash($projectRoot, 'composer.lock');
+ }
+
+ private static function packageLockHash(string $projectRoot): ?string
+ {
+ $parts = [];
+
+ foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
+ $hash = self::trackedHash($projectRoot, $name);
+
+ if ($hash !== null) {
+ $parts[] = $name.':'.$hash;
+ }
+ }
+
+ return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
+ }
+
+ private static function trackedHash(string $projectRoot, string $relativePath): ?string
+ {
+ if (! self::isTrackedByGit($projectRoot, $relativePath)) {
+ return null;
+ }
+
+ return self::hashIfExists($projectRoot.'/'.$relativePath);
+ }
+
+ /**
+ * Returns true when the file exists and is not gitignored.
+ *
+ * Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
+ * regenerate per-machine with OS-specific optional deps, which would
+ * otherwise force a fingerprint mismatch on every fetched baseline.
+ */
+ private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
+ {
+ if (! is_file($projectRoot.'/'.$relativePath)) {
+ return false;
+ }
+
+ static $cache = [];
+
+ $key = $projectRoot."\0".$relativePath;
+
+ if (isset($cache[$key])) {
+ return $cache[$key];
+ }
+
+ if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
+ return $cache[$key] = true;
+ }
+
+ $finder = (new Finder)
+ ->in($projectRoot)
+ ->depth('== 0')
+ ->name($relativePath)
+ ->ignoreVCSIgnored(true);
+
+ return $cache[$key] = $finder->hasResults();
+ }
+
+ private static function contentHashOrNull(string $path): ?string
+ {
+ if (! is_file($path)) {
+ return null;
+ }
+
+ $hash = ContentHash::of($path);
+
+ return $hash === false ? null : $hash;
+ }
+
+ private static function hashIfExists(string $path): ?string
+ {
+ if (! is_file($path)) {
+ return null;
+ }
+
+ $hash = @hash_file('xxh128', $path);
+
+ return $hash === false ? null : $hash;
+ }
+}
diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php
new file mode 100644
index 00000000..415e5291
--- /dev/null
+++ b/src/Plugins/Tia/Graph.php
@@ -0,0 +1,1491 @@
+ */
+ private array $files = [];
+
+ /** @var array */
+ private array $fileIds = [];
+
+ /** @var array> */
+ private array $edges = [];
+
+ /** @var array> */
+ private array $testTables = [];
+
+ /** @var array> */
+ private array $testInertiaComponents = [];
+
+ /** @var array> */
+ private array $jsFileToComponents = [];
+
+ /** @var array */
+ private array $fingerprint = [];
+
+ /**
+ * @var array,
+ * results: array
+ * }>
+ */
+ private array $baselines = [];
+
+ private readonly string $projectRoot;
+
+ /** @var array|null */
+ private ?array $archTestFiles = null;
+
+ /** @var array */
+ private array $realpathCache = [];
+
+ public function __construct(string $projectRoot)
+ {
+ $real = @realpath($projectRoot);
+
+ $this->projectRoot = $real !== false ? $real : $projectRoot;
+ }
+
+ public function link(string $testFile, string $sourceFile): void
+ {
+ $testRel = $this->relative($testFile);
+ $sourceRel = $this->relative($sourceFile);
+
+ if ($sourceRel === null || $testRel === null) {
+ return;
+ }
+
+ if (! isset($this->fileIds[$sourceRel])) {
+ $id = count($this->files);
+ $this->files[$id] = $sourceRel;
+ $this->fileIds[$sourceRel] = $id;
+ }
+
+ $this->edges[$testRel][] = $this->fileIds[$sourceRel];
+ }
+
+ /**
+ * @param array $changedFiles Absolute or relative paths.
+ * @return array
+ */
+ public function affected(array $changedFiles): array
+ {
+ [$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) {
+ continue;
+ }
+
+ if ($this->isMigrationPath($rel)) {
+ $migrations[] = $rel;
+ } else {
+ $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 = [];
+ $unparseable = [];
+
+ foreach ($migrationPaths as $rel) {
+ $tables = $this->tablesForMigration($rel);
+
+ if ($tables === []) {
+ $unparseable[] = $rel;
+
+ continue;
+ }
+
+ foreach ($tables as $table) {
+ $changedTables[$table] = true;
+ }
+ }
+
+ if ($changedTables !== []) {
+ foreach ($this->testTables as $testFile => $tables) {
+ if (isset($affectedSet[$testFile])) {
+ continue;
+ }
+
+ foreach ($tables as $table) {
+ if (isset($changedTables[$table])) {
+ $affectedSet[$testFile] = true;
+
+ break;
+ }
+ }
+ }
+ }
+
+ 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) {
+ if (! $this->isGlobalFrontendRuntimePath($rel)) {
+ continue;
+ }
+
+ foreach (array_keys($this->testInertiaComponents) as $testFile) {
+ $affectedSet[$testFile] = true;
+ }
+
+ $globalFrontendRuntimeFiles[$rel] = true;
+ }
+
+ $changedComponents = [];
+ $preciselyHandledPages = [];
+
+ foreach ($nonMigrationPaths as $rel) {
+ $component = $this->componentForInertiaPage($rel);
+
+ if ($component === null) {
+ continue;
+ }
+
+ if ($this->anyTestUses($this->testInertiaComponents, $component)) {
+ $changedComponents[$component] = true;
+ $preciselyHandledPages[$rel] = true;
+ }
+ }
+
+ $sharedFilesResolved = [];
+
+ foreach ($nonMigrationPaths as $rel) {
+ if (isset($globalFrontendRuntimeFiles[$rel])) {
+ continue;
+ }
+ 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;
+ $touchedAny = true;
+ }
+ }
+
+ if ($touchedAny) {
+ $sharedFilesResolved[$rel] = true;
+ }
+ }
+
+ $newJsFiles = [];
+
+ foreach ($nonMigrationPaths as $rel) {
+ if (isset($globalFrontendRuntimeFiles[$rel])) {
+ continue;
+ }
+ if (isset($preciselyHandledPages[$rel])) {
+ continue;
+ }
+ if (isset($sharedFilesResolved[$rel])) {
+ continue;
+ }
+ if (isset($this->jsFileToComponents[$rel])) {
+ continue;
+ }
+ if (! str_starts_with($rel, 'resources/js/')) {
+ continue;
+ }
+ $newJsFiles[] = $rel;
+ }
+
+ if ($newJsFiles !== []) {
+ $this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
+ }
+
+ if ($changedComponents !== []) {
+ foreach ($this->testInertiaComponents as $testFile => $components) {
+ if (isset($affectedSet[$testFile])) {
+ continue;
+ }
+
+ foreach ($components as $component) {
+ if (isset($changedComponents[$component])) {
+ $affectedSet[$testFile] = true;
+
+ break;
+ }
+ }
+ }
+ }
+
+ 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;
+
+ foreach ($nonMigrationPaths as $rel) {
+ if ($this->isProjectSourcePhp($rel)) {
+ $sourcePhpChanged = true;
+ }
+
+ if (isset($this->fileIds[$rel])) {
+ $changedIds[$this->fileIds[$rel]] = true;
+
+ continue;
+ }
+
+ if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
+ if (! is_file($this->projectRoot.'/'.$rel)) {
+ continue;
+ }
+
+ if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
+ $unknownSourceDirs[dirname($rel)] = true;
+ }
+ }
+ }
+
+ if ($sourcePhpChanged) {
+ foreach (array_keys($this->edges) as $testFile) {
+ if ($this->isArchTestFile($testFile)) {
+ $affectedSet[$testFile] = true;
+ }
+ }
+ }
+
+ foreach ($this->edges as $testFile => $ids) {
+ if (isset($affectedSet[$testFile])) {
+ continue;
+ }
+
+ foreach ($ids as $id) {
+ if (isset($changedIds[$id])) {
+ $affectedSet[$testFile] = true;
+
+ break;
+ }
+ }
+ }
+
+ 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) {
+ 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.
+ *
+ * @param list $nonMigrationPaths
+ * @param array $affectedSet
+ * @return array
+ */
+ private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
+ {
+ $staticallyHandled = [];
+
+ foreach ($nonMigrationPaths as $rel) {
+ if (isset($this->fileIds[$rel])) {
+ continue;
+ }
+ if (! $this->isBladePath($rel)) {
+ continue;
+ }
+ if (! is_file($this->projectRoot.'/'.$rel)) {
+ continue;
+ }
+
+ $bladeAffected = $this->affectedByStaticBladeUsage($rel);
+
+ if ($bladeAffected !== []) {
+ foreach ($bladeAffected as $testFile) {
+ $affectedSet[$testFile] = true;
+ }
+
+ $staticallyHandled[$rel] = true;
+ } elseif ($this->isBladeComponentPath($rel)) {
+ $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;
+ }
+ if (isset($sharedFilesResolved[$rel])) {
+ continue;
+ }
+ if (isset($staticallyHandledBlade[$rel])) {
+ continue;
+ }
+ if (! isset($this->fileIds[$rel])) {
+ if (! is_file($this->projectRoot.'/'.$rel)) {
+ continue;
+ }
+
+ $unknownToGraph[] = $rel;
+ }
+ }
+
+ /** @var WatchPatterns $watchPatterns */
+ $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
+
+ $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $unknownToGraph);
+ $allTestFiles = array_keys($this->edges);
+
+ foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
+ $affectedSet[$testFile] = true;
+ }
+ }
+
+ /**
+ * @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;
+ }
+
+ $depDir = dirname($this->files[$id]);
+
+ if (isset($unknownSourceDirs[$depDir])) {
+ $affectedSet[$testFile] = true;
+
+ break;
+ }
+ }
+ }
+ }
+
+ public function knowsTest(string $testFile): bool
+ {
+ $rel = $this->relative($testFile);
+
+ return $rel !== null && isset($this->edges[$rel]);
+ }
+
+ /** @return array */
+ public function allTestFiles(): array
+ {
+ return array_keys($this->edges);
+ }
+
+ /**
+ * @param array $fingerprint
+ */
+ public function setFingerprint(array $fingerprint): void
+ {
+ $this->fingerprint = $fingerprint;
+ }
+
+ /**
+ * @return array
+ */
+ public function fingerprint(): array
+ {
+ return $this->fingerprint;
+ }
+
+ public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
+ {
+ $baseline = $this->baselineFor($branch, $fallbackBranch);
+
+ return $baseline['sha'];
+ }
+
+ public function setRecordedAtSha(string $branch, ?string $sha): void
+ {
+ $this->ensureBaseline($branch);
+ $this->baselines[$branch]['sha'] = $sha;
+ }
+
+ public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
+ {
+ $this->ensureBaseline($branch);
+
+ $entry = [
+ 'status' => $status,
+ 'message' => $message,
+ 'time' => $time,
+ 'assertions' => $assertions,
+ ];
+
+ if ($file !== null) {
+ $rel = $this->relative($file);
+
+ if ($rel !== null) {
+ $entry['file'] = $rel;
+ }
+ }
+
+ $this->baselines[$branch]['results'][$testId] = $entry;
+ }
+
+ public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
+ {
+ $baseline = $this->baselineFor($branch, $fallbackBranch);
+
+ if (! isset($baseline['results'][$testId]['assertions'])) {
+ return null;
+ }
+
+ return $baseline['results'][$testId]['assertions'];
+ }
+
+ public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
+ {
+ $baseline = $this->baselineFor($branch, $fallbackBranch);
+
+ if (! isset($baseline['results'][$testId])) {
+ return null;
+ }
+
+ $r = $baseline['results'][$testId];
+
+ return match ($r['status']) {
+ 0 => TestStatus::success(),
+ 1 => TestStatus::skipped($r['message']),
+ 2 => TestStatus::incomplete($r['message']),
+ 3 => TestStatus::notice($r['message']),
+ 4 => TestStatus::deprecation($r['message']),
+ 5 => TestStatus::risky($r['message']),
+ 6 => TestStatus::warning($r['message']),
+ 7 => TestStatus::failure($r['message']),
+ 8 => TestStatus::error($r['message']),
+ default => TestStatus::unknown(),
+ };
+ }
+
+ /**
+ * @return array
+ */
+ public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array
+ {
+ $baseline = $this->baselineFor($branch, $fallbackBranch);
+ $files = [];
+
+ foreach ($baseline['results'] as $result) {
+ if (! $this->shouldRerun($result['status'])) {
+ continue;
+ }
+
+ $file = $result['file'] ?? null;
+ if ($file === null) {
+ continue;
+ }
+ if ($file === '') {
+ continue;
+ }
+
+ $rel = $this->relative($file);
+
+ if ($rel !== null) {
+ $files[$rel] = true;
+ }
+ }
+
+ return array_keys($files);
+ }
+
+ public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
+ {
+ $baseline = $this->baselineFor($branch, $fallbackBranch);
+
+ foreach ($baseline['results'] as $result) {
+ if (! $this->shouldRerun($result['status'])) {
+ continue;
+ }
+
+ $file = $result['file'] ?? null;
+
+ if ($file === null || $file === '' || $this->relative($file) === null) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function shouldRerun(int $status): bool
+ {
+ $testStatus = TestStatus::from($status);
+
+ if ($testStatus->isFailure() || $testStatus->isError()) {
+ return true;
+ }
+
+ $configuration = Registry::get();
+
+ if ($testStatus->isRisky()) {
+ return $configuration->failOnRisky();
+ }
+
+ if ($testStatus->isWarning()) {
+ if ($configuration->failOnWarning()) {
+ return true;
+ }
+
+ return $configuration->displayDetailsOnTestsThatTriggerWarnings();
+ }
+
+ if ($testStatus->isNotice()) {
+ if ($configuration->failOnNotice()) {
+ return true;
+ }
+
+ return $configuration->displayDetailsOnTestsThatTriggerNotices();
+ }
+
+ if ($testStatus->isDeprecation()) {
+ if ($configuration->failOnDeprecation()) {
+ return true;
+ }
+
+ return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
+ }
+
+ if ($testStatus->isIncomplete()) {
+ if ($configuration->failOnIncomplete()) {
+ return true;
+ }
+
+ return $configuration->displayDetailsOnIncompleteTests();
+ }
+
+ if ($testStatus->isSkipped()) {
+ if ($configuration->failOnSkipped()) {
+ return true;
+ }
+
+ return $configuration->displayDetailsOnSkippedTests();
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array $tree project-relative path → content hash
+ */
+ public function setLastRunTree(string $branch, array $tree): void
+ {
+ $this->ensureBaseline($branch);
+ $this->baselines[$branch]['tree'] = $tree;
+ }
+
+ public function clearResults(string $branch): void
+ {
+ $this->ensureBaseline($branch);
+ $this->baselines[$branch]['results'] = [];
+ }
+
+ /**
+ * @return array
+ */
+ public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
+ {
+ return $this->baselineFor($branch, $fallbackBranch)['tree'];
+ }
+
+ /**
+ * @return array{sha: ?string, tree: array, results: array}
+ */
+ private function baselineFor(string $branch, string $fallbackBranch): array
+ {
+ if (isset($this->baselines[$branch])) {
+ return $this->baselines[$branch];
+ }
+
+ if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) {
+ return $this->baselines[$fallbackBranch];
+ }
+
+ return ['sha' => null, 'tree' => [], 'results' => []];
+ }
+
+ private function ensureBaseline(string $branch): void
+ {
+ if (! isset($this->baselines[$branch])) {
+ $this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []];
+ }
+ }
+
+ /**
+ * @param array> $testToFiles
+ */
+ public function replaceEdges(array $testToFiles): void
+ {
+ foreach ($testToFiles as $testFile => $sources) {
+ $testRel = $this->relative($testFile);
+
+ if ($testRel === null) {
+ continue;
+ }
+
+ $this->edges[$testRel] = [];
+
+ foreach ($sources as $source) {
+ $this->link($testFile, $source);
+ }
+
+ $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
+ }
+ }
+
+ /**
+ * @param array> $testToTables
+ */
+ public function replaceTestTables(array $testToTables): void
+ {
+ foreach ($testToTables as $testFile => $tables) {
+ $testRel = $this->relative($testFile);
+
+ if ($testRel === null) {
+ continue;
+ }
+
+ $normalised = [];
+
+ foreach ($tables as $table) {
+ $lower = strtolower($table);
+
+ if ($lower !== '') {
+ $normalised[$lower] = true;
+ }
+ }
+
+ $names = array_keys($normalised);
+ sort($names);
+
+ $this->testTables[$testRel] = $names;
+ }
+ }
+
+ /**
+ * @param array> $testToComponents
+ */
+ public function replaceTestInertiaComponents(array $testToComponents): void
+ {
+ foreach ($testToComponents as $testFile => $components) {
+ $testRel = $this->relative($testFile);
+
+ if ($testRel === null) {
+ continue;
+ }
+
+ $normalised = [];
+
+ foreach ($components as $component) {
+ if ($component !== '') {
+ $normalised[$component] = true;
+ }
+ }
+
+ $names = array_keys($normalised);
+ sort($names);
+
+ $this->testInertiaComponents[$testRel] = $names;
+ }
+ }
+
+ /**
+ * @param array> $fileToComponents
+ */
+ public function replaceJsFileToComponents(array $fileToComponents): void
+ {
+ $out = [];
+
+ foreach ($fileToComponents as $path => $components) {
+ if ($path === '') {
+ continue;
+ }
+ $names = [];
+
+ foreach ($components as $component) {
+ if ($component !== '') {
+ $names[$component] = true;
+ }
+ }
+
+ if ($names === []) {
+ continue;
+ }
+
+ $keys = array_keys($names);
+ sort($keys);
+ $out[$path] = $keys;
+ }
+
+ if ($out === []) {
+ return;
+ }
+
+ ksort($out);
+
+ $this->jsFileToComponents = $out;
+ }
+
+ private function isMigrationPath(string $rel): bool
+ {
+ return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
+ }
+
+ private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
+ {
+ static $prefixes = [
+ 'app/Providers/',
+ 'app/Listeners/',
+ 'app/Events/',
+ 'app/Observers/',
+ 'app/Policies/',
+ 'app/Console/Commands/',
+ 'app/Mail/',
+ 'app/Notifications/',
+ 'app/Nova/Actions/',
+ 'app/Nova/Dashboards/',
+ 'app/Nova/Lenses/',
+ 'app/Nova/Metrics/',
+ 'app/Nova/Policies/',
+ 'app/Nova/Resources/',
+ 'app/Projectors/',
+ 'app/Reactors/',
+ 'database/factories/',
+ 'database/seeders/',
+ ];
+
+ foreach ($prefixes as $prefix) {
+ if (str_starts_with($rel, (string) $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function isProjectSourcePhp(string $rel): bool
+ {
+ return str_ends_with($rel, '.php')
+ && ! $this->isBladePath($rel)
+ && ! str_starts_with($rel, 'tests/')
+ && ! str_starts_with($rel, 'vendor/')
+ && ! str_starts_with($rel, 'storage/framework/')
+ && ! str_starts_with($rel, 'bootstrap/cache/');
+ }
+
+ private function isArchTestFile(string $rel): bool
+ {
+ return isset($this->archTestFiles()[$rel]);
+ }
+
+ /**
+ * @return array
+ */
+ private function archTestFiles(): array
+ {
+ if ($this->archTestFiles !== null) {
+ return $this->archTestFiles;
+ }
+
+ $this->archTestFiles = [];
+ $repo = TestSuite::getInstance()->tests;
+
+ foreach ($repo->getFilenames() as $filename) {
+ $factory = $repo->get($filename);
+
+ if (! $factory instanceof TestCaseFactory) {
+ continue;
+ }
+
+ foreach ($factory->methods as $method) {
+ if (! $this->methodHasGroup($method, 'arch')) {
+ continue;
+ }
+
+ $rel = $this->relative($filename);
+
+ if ($rel !== null) {
+ $this->archTestFiles[$rel] = true;
+ }
+
+ break;
+ }
+ }
+
+ foreach (array_keys($this->edges) as $testFile) {
+ if (isset($this->archTestFiles[$testFile])) {
+ continue;
+ }
+ if ($this->testSourceDeclaresArchGroup($testFile)) {
+ $this->archTestFiles[$testFile] = true;
+ }
+ }
+
+ return $this->archTestFiles;
+ }
+
+ private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
+ {
+ if (in_array($group, $method->groups, true)) {
+ return true;
+ }
+
+ foreach ($method->attributes as $attribute) {
+ if ($attribute->name !== Group::class) {
+ continue;
+ }
+
+ foreach ($attribute->arguments as $argument) {
+ if ($argument === $group) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private function testSourceDeclaresArchGroup(string $rel): bool
+ {
+ $source = @file_get_contents($this->projectRoot.'/'.$rel);
+
+ if ($source === false) {
+ return false;
+ }
+
+ return preg_match('/\barch\s*\(/', $source) === 1
+ || preg_match('/->\s*group\s*\(\s*[\'\"]arch[\'\"]/', $source) === 1
+ || preg_match('/#\[\s*(?:\\\\)?(?:PHPUnit\\\\Framework\\\\Attributes\\\\)?Group\s*\(\s*[\'\"]arch[\'\"]/', $source) === 1;
+ }
+
+ private function isBladePath(string $rel): bool
+ {
+ return str_starts_with($rel, 'resources/views/') && str_ends_with($rel, '.blade.php');
+ }
+
+ private function isBladeComponentPath(string $rel): bool
+ {
+ return str_starts_with($rel, 'resources/views/components/') && str_ends_with($rel, '.blade.php');
+ }
+
+ /**
+ * @return list Project-relative test files.
+ */
+ private function affectedByStaticBladeUsage(string $changedBlade): array
+ {
+ $ancestors = $this->bladeAncestorsFor($changedBlade);
+
+ if ($ancestors === []) {
+ return [];
+ }
+
+ $ancestorIds = [];
+ foreach ($ancestors as $ancestor) {
+ if (isset($this->fileIds[$ancestor])) {
+ $ancestorIds[$this->fileIds[$ancestor]] = true;
+ }
+ }
+
+ if ($ancestorIds === []) {
+ return [];
+ }
+
+ $affected = [];
+ foreach ($this->edges as $testFile => $ids) {
+ foreach ($ids as $id) {
+ if (isset($ancestorIds[$id])) {
+ $affected[$testFile] = true;
+
+ break;
+ }
+ }
+ }
+
+ return array_keys($affected);
+ }
+
+ /**
+ * @return list Project-relative Blade files that statically depend on $changedBlade, directly or transitively.
+ */
+ private function bladeAncestorsFor(string $changedBlade): array
+ {
+ $allBladeFiles = $this->allBladeFiles();
+
+ if ($allBladeFiles === []) {
+ return [];
+ }
+
+ $targets = [$changedBlade => true];
+ $ancestors = [];
+ $changed = true;
+
+ while ($changed) {
+ $changed = false;
+
+ foreach ($allBladeFiles as $candidate) {
+ if (isset($targets[$candidate])) {
+ continue;
+ }
+ if (isset($ancestors[$candidate])) {
+ continue;
+ }
+
+ $source = @file_get_contents($this->projectRoot.'/'.$candidate);
+ if ($source === false) {
+ continue;
+ }
+
+ foreach (array_keys($targets) as $target) {
+ if ($this->bladeSourceReferences($source, $target)) {
+ $ancestors[$candidate] = true;
+ $targets[$candidate] = true;
+ $changed = true;
+
+ break;
+ }
+ }
+ }
+ }
+
+ return array_keys($ancestors);
+ }
+
+ /**
+ * @return list
+ */
+ private function allBladeFiles(): array
+ {
+ $views = $this->projectRoot.'/resources/views';
+
+ if (! is_dir($views)) {
+ return [];
+ }
+
+ $files = [];
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($views, \FilesystemIterator::SKIP_DOTS),
+ );
+
+ foreach ($iterator as $file) {
+ assert($file instanceof \SplFileInfo);
+
+ if (! $file->isFile()) {
+ continue;
+ }
+ $path = $file->getPathname();
+ if (! str_ends_with($path, '.blade.php')) {
+ continue;
+ }
+
+ $files[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->projectRoot) + 1));
+ }
+
+ sort($files);
+
+ return $files;
+ }
+
+ private function bladeSourceReferences(string $source, string $targetBlade): bool
+ {
+ $view = $this->viewNameForBlade($targetBlade);
+
+ if ($view !== null) {
+ $quoted = preg_quote($view, '#');
+
+ if (preg_match('#@(include|includeIf|includeWhen|includeUnless|extends|component|each)\s*\([^)]*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) {
+ return true;
+ }
+
+ if (preg_match('#\b(view|View::make)\s*\(\s*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) {
+ return true;
+ }
+ }
+
+ foreach ($this->componentNamesForBlade($targetBlade) as $component) {
+ $quoted = preg_quote($component, '#');
+
+ if (preg_match('#/.:])#i', $source) === 1) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function viewNameForBlade(string $rel): ?string
+ {
+ if (! $this->isBladePath($rel)) {
+ return null;
+ }
+
+ $tail = substr($rel, strlen('resources/views/'));
+ $tail = substr($tail, 0, -strlen('.blade.php'));
+
+ return str_replace('/', '.', $tail);
+ }
+
+ /**
+ * @return list
+ */
+ private function componentNamesForBlade(string $rel): array
+ {
+ if (! $this->isBladeComponentPath($rel)) {
+ return [];
+ }
+
+ $tail = substr($rel, strlen('resources/views/components/'));
+ $tail = substr($tail, 0, -strlen('.blade.php'));
+ $name = str_replace('/', '.', $tail);
+
+ return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
+ }
+
+ /** @return list */
+ private function tablesForMigration(string $rel): array
+ {
+ $absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel;
+
+ if (! is_file($absolute)) {
+ return [];
+ }
+
+ $content = @file_get_contents($absolute);
+
+ if ($content === false) {
+ return [];
+ }
+
+ return TableExtractor::fromMigrationSource($content);
+ }
+
+ private function componentForInertiaPage(string $rel): ?string
+ {
+ foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
+ if (! str_starts_with($rel, $prefix)) {
+ continue;
+ }
+
+ $tail = substr($rel, strlen($prefix));
+ $dot = strrpos($tail, '.');
+
+ if ($dot === false) {
+ return null;
+ }
+
+ $extension = substr($tail, $dot + 1);
+
+ if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
+ return null;
+ }
+
+ $name = substr($tail, 0, $dot);
+
+ return $name === '' ? null : $name;
+ }
+
+ return null;
+ }
+
+ private function isGlobalFrontendRuntimePath(string $rel): bool
+ {
+ if (! str_starts_with($rel, 'resources/js/')) {
+ return false;
+ }
+
+ $tail = substr($rel, strlen('resources/js/'));
+ $dot = strrpos($tail, '.');
+
+ if ($dot === false) {
+ return false;
+ }
+
+ $name = substr($tail, 0, $dot);
+ $extension = substr($tail, $dot + 1);
+
+ return in_array($extension, ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte'], true)
+ && in_array($name, ['App', 'app', 'bootstrap', 'echo', 'favicon'], true);
+ }
+
+ /** @param array> $edges */
+ private function anyTestUses(array $edges, string $component): bool
+ {
+ foreach ($edges as $components) {
+ if (in_array($component, $components, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function pruneMissingTests(): void
+ {
+ $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
+
+ foreach (array_keys($this->edges) as $testRel) {
+ if (! is_file($root.$testRel)) {
+ unset($this->edges[$testRel]);
+ }
+ }
+
+ foreach (array_keys($this->testInertiaComponents) as $testRel) {
+ if (! is_file($root.$testRel)) {
+ unset($this->testInertiaComponents[$testRel]);
+ }
+ }
+
+ foreach (array_keys($this->testTables) as $testRel) {
+ if (! is_file($root.$testRel)) {
+ unset($this->testTables[$testRel]);
+ }
+ }
+ }
+
+ /**
+ * Prune baseline result entries whose test files were just executed but whose
+ * test IDs are no longer present (e.g. the test method was removed or renamed).
+ *
+ * @param array $touchedFiles Absolute or project-relative paths.
+ * @param array $keepTestIds Test IDs that produced a result this run.
+ */
+ public function pruneStaleResults(string $branch, array $touchedFiles, array $keepTestIds): void
+ {
+ if (! isset($this->baselines[$branch]['results'])) {
+ return;
+ }
+
+ $touched = [];
+ foreach ($touchedFiles as $file) {
+ $rel = $this->relative($file);
+
+ if ($rel !== null) {
+ $touched[$rel] = true;
+ }
+ }
+
+ if ($touched === []) {
+ return;
+ }
+
+ $keep = array_fill_keys($keepTestIds, true);
+
+ foreach ($this->baselines[$branch]['results'] as $testId => $result) {
+ $file = $result['file'] ?? null;
+ if (! is_string($file)) {
+ continue;
+ }
+ if (! isset($touched[$file])) {
+ continue;
+ }
+
+ if (isset($keep[$testId])) {
+ continue;
+ }
+
+ unset($this->baselines[$branch]['results'][$testId]);
+ }
+ }
+
+ public static function decode(string $json, string $projectRoot): ?self
+ {
+ $data = json_decode($json, true);
+
+ if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
+ return null;
+ }
+
+ $graph = new self($projectRoot);
+ $graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
+ $graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
+ $graph->fileIds = array_flip($graph->files);
+ $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
+ $graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
+
+ $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 = [
+ 'schema' => 1,
+ 'fingerprint' => $this->fingerprint,
+ 'files' => $this->files,
+ 'edges' => $this->edges,
+ 'baselines' => $this->baselines,
+ 'test_tables' => $this->testTables,
+ 'test_inertia_components' => $this->testInertiaComponents,
+ 'js_file_to_components' => $this->jsFileToComponents,
+ ];
+
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
+
+ return $json === false ? null : $json;
+ }
+
+ private function relative(string $path): ?string
+ {
+ if ($path === '' || $path === 'unknown') {
+ return null;
+ }
+
+ if (str_contains($path, "eval()'d")) {
+ return null;
+ }
+
+ $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
+
+ $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
+ || (strlen($path) >= 2 && $path[1] === ':');
+ if ($isAbsolute) {
+ if (array_key_exists($path, $this->realpathCache)) {
+ $real = $this->realpathCache[$path];
+ } else {
+ $real = $this->realpathCache[$path] = @realpath($path);
+ }
+
+ if ($real === false) {
+ $real = $path;
+ }
+
+ if (! str_starts_with($real, $root)) {
+ return null;
+ }
+
+ $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
+ } else {
+ $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
+
+ while (str_starts_with($relative, './')) {
+ $relative = substr($relative, 2);
+ }
+ }
+
+ if (str_starts_with($relative, 'vendor/')) {
+ return null;
+ }
+
+ return $relative;
+ }
+}
diff --git a/src/Plugins/Tia/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php
new file mode 100644
index 00000000..d0e117fa
--- /dev/null
+++ b/src/Plugins/Tia/JsModuleGraph.php
@@ -0,0 +1,397 @@
+
+ */
+ public const array VITE_CONFIG_NAMES = [
+ 'vite.config.ts',
+ 'vite.config.js',
+ 'vite.config.mjs',
+ 'vite.config.cjs',
+ 'vite.config.mts',
+ ];
+
+ /**
+ * Candidate page directories, in priority order. Must stay in sync with
+ * `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
+ *
+ * @var list
+ */
+ private const array PAGE_DIR_CANDIDATES = [
+ 'resources/js/Pages',
+ 'resources/js/pages',
+ 'assets/js/Pages',
+ 'assets/js/pages',
+ 'assets/Pages',
+ 'assets/pages',
+ ];
+
+ /**
+ * @var list
+ */
+ private const array PAGE_EXTENSIONS = [
+ 'vue', 'svelte',
+ 'tsx', 'jsx',
+ 'ts', 'js',
+ 'mts', 'cts', 'mjs', 'cjs',
+ ];
+
+ /**
+ * @return array>
+ */
+ public static function build(string $projectRoot): array
+ {
+ $result = self::resolve($projectRoot);
+
+ return $result ?? [];
+ }
+
+ /**
+ * @return array>|null
+ */
+ public static function buildStrict(string $projectRoot): ?array
+ {
+ return self::resolve($projectRoot);
+ }
+
+ public static function isApplicable(string $projectRoot): bool
+ {
+ if (! self::hasViteConfig($projectRoot)) {
+ return false;
+ }
+
+ return self::firstExistingPagesDir($projectRoot) !== null;
+ }
+
+ private static function firstExistingPagesDir(string $projectRoot): ?string
+ {
+ foreach (self::PAGE_DIR_CANDIDATES as $rel) {
+ $abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
+
+ if (! is_dir($abs)) {
+ continue;
+ }
+
+ if (self::dirHasPageFile($abs)) {
+ return $abs;
+ }
+ }
+
+ return null;
+ }
+
+ private static function dirHasPageFile(string $dir): bool
+ {
+ try {
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::LEAVES_ONLY,
+ );
+ } catch (\UnexpectedValueException) {
+ return false;
+ }
+
+ /** @var \SplFileInfo $file */
+ foreach ($iterator as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @return array>|null
+ */
+ private static function resolve(string $projectRoot): ?array
+ {
+ $fingerprint = self::fingerprint($projectRoot);
+
+ if ($fingerprint !== null) {
+ $cached = self::readCache($projectRoot, $fingerprint);
+
+ if ($cached !== null) {
+ return $cached;
+ }
+ }
+
+ $process = self::buildNodeProcess($projectRoot);
+
+ if (! $process instanceof Process) {
+ return null;
+ }
+
+ $process->run();
+
+ if (! $process->isSuccessful()) {
+ return null;
+ }
+
+ $result = self::parseNodeOutput($process->getOutput());
+
+ if ($result !== null && $fingerprint !== null) {
+ self::writeCache($projectRoot, $fingerprint, $result);
+ }
+
+ return $result;
+ }
+
+ private static function buildNodeProcess(string $projectRoot): ?Process
+ {
+ if (! self::hasViteConfig($projectRoot)) {
+ return null;
+ }
+
+ if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
+ return null;
+ }
+
+ $nodeBinary = (new ExecutableFinder)->find('node');
+
+ if ($nodeBinary === null) {
+ return null;
+ }
+
+ $helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
+
+ if (! is_file($helperPath)) {
+ return null;
+ }
+
+ $process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
+ $process->setTimeout(self::NODE_TIMEOUT_SECONDS);
+
+ return $process;
+ }
+
+ /**
+ * @return array>|null
+ */
+ private static function parseNodeOutput(string $output): ?array
+ {
+ /** @var mixed $decoded */
+ $decoded = json_decode($output, true);
+
+ if (! is_array($decoded)) {
+ return null;
+ }
+
+ $out = [];
+
+ foreach ($decoded as $path => $components) {
+ if (! is_string($path)) {
+ continue;
+ }
+ if (! is_array($components)) {
+ continue;
+ }
+ $names = [];
+
+ foreach ($components as $component) {
+ if (is_string($component) && $component !== '') {
+ $names[] = $component;
+ }
+ }
+
+ if ($names !== []) {
+ sort($names);
+ $out[$path] = $names;
+ }
+ }
+
+ ksort($out);
+
+ return $out;
+ }
+
+ private static function fingerprint(string $projectRoot): ?string
+ {
+ $parts = [];
+
+ foreach (self::VITE_CONFIG_NAMES as $name) {
+ $path = $projectRoot.DIRECTORY_SEPARATOR.$name;
+
+ if (! is_file($path)) {
+ continue;
+ }
+
+ $stat = @stat($path);
+ $bytes = @file_get_contents($path);
+
+ $parts[] = 'config:'.$name
+ .':'.($stat === false ? '0' : (string) $stat['mtime'])
+ .':'.($stat === false ? '0' : (string) $stat['size'])
+ .':'.($bytes === false ? '' : hash('sha256', $bytes));
+ }
+
+ if ($parts === []) {
+ return null;
+ }
+
+ $override = getenv('TIA_VITE_PAGES_DIR');
+
+ if (is_string($override) && $override !== '') {
+ $parts[] = 'pagesDirOverride:'.$override;
+ }
+
+ $pagesDir = self::firstExistingPagesDir($projectRoot);
+
+ if ($pagesDir !== null) {
+ $parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
+ }
+
+ $jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
+
+ if ($jsRoot !== null && is_dir($jsRoot)) {
+ $entries = [];
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::LEAVES_ONLY,
+ );
+
+ /** @var \SplFileInfo $file */
+ foreach ($iterator as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ $entries[] = $file->getPathname()
+ .':'.$file->getSize()
+ .':'.$file->getMTime();
+ }
+
+ sort($entries);
+
+ $parts[] = 'js:'.hash('sha256', implode("\n", $entries));
+ }
+
+ return hash('sha256', implode('|', $parts));
+ }
+
+ /**
+ * @return array>|null
+ */
+ private static function readCache(string $projectRoot, string $fingerprint): ?array
+ {
+ $path = self::cachePath($projectRoot);
+
+ if (! is_file($path)) {
+ return null;
+ }
+
+ $raw = @file_get_contents($path);
+
+ if ($raw === false) {
+ return null;
+ }
+
+ /** @var mixed $decoded */
+ $decoded = json_decode($raw, true);
+
+ if (! is_array($decoded)) {
+ return null;
+ }
+
+ if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
+ return null;
+ }
+
+ $graph = $decoded['graph'] ?? null;
+
+ if (! is_array($graph)) {
+ return null;
+ }
+
+ $out = [];
+
+ foreach ($graph as $key => $value) {
+ if (! is_string($key)) {
+ continue;
+ }
+ if (! is_array($value)) {
+ continue;
+ }
+ $names = [];
+
+ foreach ($value as $name) {
+ if (is_string($name) && $name !== '') {
+ $names[] = $name;
+ }
+ }
+
+ $out[$key] = $names;
+ }
+
+ return $out;
+ }
+
+ /**
+ * @param array> $graph
+ */
+ private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
+ {
+ $path = self::cachePath($projectRoot);
+ $dir = dirname($path);
+
+ if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
+ return;
+ }
+
+ $payload = json_encode([
+ 'fingerprint' => $fingerprint,
+ 'graph' => $graph,
+ ]);
+
+ if ($payload === false) {
+ return;
+ }
+
+ $tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
+
+ if (@file_put_contents($tmp, $payload) === false) {
+ return;
+ }
+
+ if (! @rename($tmp, $path)) {
+ @unlink($tmp);
+ }
+ }
+
+ private static function cachePath(string $projectRoot): string
+ {
+ return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
+ }
+
+ private static function hasViteConfig(string $projectRoot): bool
+ {
+ foreach (self::VITE_CONFIG_NAMES as $name) {
+ if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php
new file mode 100644
index 00000000..0240aa9f
--- /dev/null
+++ b/src/Plugins/Tia/Recorder.php
@@ -0,0 +1,355 @@
+> */
+ private array $perTestFiles = [];
+
+ /** @var array> */
+ private array $perTestTables = [];
+
+ /** @var array> */
+ private array $perTestInertiaComponents = [];
+
+ /** @var array */
+ private array $perTestUsesDatabase = [];
+
+ /** @var array */
+ private array $classFileCache = [];
+
+ /** @var array */
+ private array $classUsesDatabaseCache = [];
+
+ private bool $active = false;
+
+ private bool $driverChecked = false;
+
+ private bool $driverAvailable = false;
+
+ private string $driver = 'none';
+
+ private ?SourceScope $sourceScope = null;
+
+ public function activate(): void
+ {
+ $this->active = true;
+ }
+
+ public function isActive(): bool
+ {
+ return $this->active;
+ }
+
+ public function driverAvailable(): bool
+ {
+ if (! $this->driverChecked) {
+ if (function_exists('pcov\\start')) {
+ $this->driver = 'pcov';
+ $this->driverAvailable = true;
+ } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
+ $modes = \xdebug_info('mode');
+
+ if (is_array($modes) && in_array('coverage', $modes, true)) {
+ $this->driver = 'xdebug';
+ $this->driverAvailable = true;
+ }
+ }
+
+ $this->driverChecked = true;
+ }
+
+ return $this->driverAvailable;
+ }
+
+ public function beginTest(string $className, string $methodName, string $fallbackFile): void
+ {
+ if (! $this->active || ! $this->driverAvailable()) {
+ return;
+ }
+
+ if ($this->currentTestFile !== null) {
+ return;
+ }
+
+ $file = $this->resolveTestFile($className, $fallbackFile);
+
+ if ($file === null) {
+ return;
+ }
+
+ $this->currentTestFile = $file;
+
+ if ($this->classUsesDatabase($className)) {
+ $this->perTestUsesDatabase[$file] = true;
+ }
+
+ if ($this->driver === 'pcov') {
+ \pcov\clear();
+ \pcov\start();
+
+ return;
+ }
+
+ \xdebug_start_code_coverage();
+ }
+
+ public function endTest(): void
+ {
+ if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
+ return;
+ }
+
+ if ($this->driver === 'pcov') {
+ \pcov\stop();
+
+ $scope = $this->sourceScope();
+ $filesToCollectCoverageFor = [];
+
+ foreach (\pcov\waiting() as $file) {
+ if (is_string($file) && $scope->contains($file)) {
+ $filesToCollectCoverageFor[] = $file;
+ }
+ }
+
+ /** @var array $data */
+ $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
+
+ $coveredFiles = $this->filesWithExecutedLines($data);
+ } else {
+ /** @var array $data */
+ $data = \xdebug_get_code_coverage();
+ \xdebug_stop_code_coverage(true);
+
+ $coveredFiles = array_keys($data);
+ }
+
+ foreach ($coveredFiles as $sourceFile) {
+ $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
+ }
+
+ $this->currentTestFile = null;
+ }
+
+ public function linkSource(string $sourceFile): void
+ {
+ if (! $this->active) {
+ return;
+ }
+
+ if ($this->currentTestFile === null) {
+ return;
+ }
+
+ if ($sourceFile === '') {
+ return;
+ }
+
+ $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
+ }
+
+ private function classUsesDatabase(string $className): bool
+ {
+ if (array_key_exists($className, $this->classUsesDatabaseCache)) {
+ return $this->classUsesDatabaseCache[$className];
+ }
+
+ if (! class_exists($className, false)) {
+ return $this->classUsesDatabaseCache[$className] = false;
+ }
+
+ static $needles = [
+ 'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
+ 'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
+ 'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
+ ];
+
+ $reflection = new ReflectionClass($className);
+
+ do {
+ foreach (array_keys($reflection->getTraits()) as $traitName) {
+ if (isset($needles[$traitName])) {
+ return $this->classUsesDatabaseCache[$className] = true;
+ }
+ }
+
+ $reflection = $reflection->getParentClass();
+ } while ($reflection !== false && ! $reflection->isInternal());
+
+ return $this->classUsesDatabaseCache[$className] = false;
+ }
+
+ public function linkTable(string $table): void
+ {
+ if (! $this->active) {
+ return;
+ }
+
+ if ($this->currentTestFile === null) {
+ return;
+ }
+
+ if ($table === '') {
+ return;
+ }
+
+ $this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
+ }
+
+ public function linkInertiaComponent(string $component): void
+ {
+ if (! $this->active) {
+ return;
+ }
+
+ if ($this->currentTestFile === null) {
+ return;
+ }
+
+ if ($component === '') {
+ return;
+ }
+
+ $this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
+ }
+
+ /** @return array> */
+ public function perTestFiles(): array
+ {
+ $out = [];
+
+ foreach ($this->perTestFiles as $testFile => $sources) {
+ $out[$testFile] = array_keys($sources);
+ }
+
+ return $out;
+ }
+
+ /** @return array> */
+ public function perTestTables(): array
+ {
+ $out = [];
+
+ foreach ($this->perTestTables as $testFile => $tables) {
+ $names = array_keys($tables);
+ sort($names);
+ $out[$testFile] = $names;
+ }
+
+ return $out;
+ }
+
+ /** @return array> */
+ public function perTestInertiaComponents(): array
+ {
+ $out = [];
+
+ foreach ($this->perTestInertiaComponents as $testFile => $components) {
+ $names = array_keys($components);
+ sort($names);
+ $out[$testFile] = $names;
+ }
+
+ return $out;
+ }
+
+ /** @return array */
+ public function perTestUsesDatabase(): array
+ {
+ return $this->perTestUsesDatabase;
+ }
+
+ private function resolveTestFile(string $className, string $fallbackFile): ?string
+ {
+ if (array_key_exists($className, $this->classFileCache)) {
+ $file = $this->classFileCache[$className];
+ } else {
+ $file = $this->readPestFilename($className);
+ $this->classFileCache[$className] = $file;
+ }
+
+ if ($file !== null) {
+ return $file;
+ }
+
+ if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) {
+ return $fallbackFile;
+ }
+
+ return null;
+ }
+
+ private function readPestFilename(string $className): ?string
+ {
+ if (! class_exists($className, false)) {
+ return null;
+ }
+
+ assert(property_exists($className, '__filename') && is_string($className::$__filename));
+
+ return $className::$__filename;
+ }
+
+ /**
+ * @param array $data
+ * @return list
+ */
+ private function filesWithExecutedLines(array $data): array
+ {
+ $out = [];
+
+ foreach ($data as $file => $lines) {
+ if (! is_array($lines)) {
+ continue;
+ }
+ $covered = [];
+ foreach ($lines as $line => $count) {
+ if (is_int($count) && $count > 0) {
+ $covered[] = $line;
+ }
+ }
+
+ if ($covered === []) {
+ continue;
+ }
+
+ $lineKeys = array_keys($lines);
+ if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
+ continue;
+ }
+
+ $out[] = $file;
+ }
+
+ return $out;
+ }
+
+ private function sourceScope(): SourceScope
+ {
+ return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
+ }
+
+ public function reset(): void
+ {
+ $this->currentTestFile = null;
+ $this->perTestFiles = [];
+ $this->perTestTables = [];
+ $this->perTestInertiaComponents = [];
+ $this->perTestUsesDatabase = [];
+ $this->classFileCache = [];
+ $this->classUsesDatabaseCache = [];
+ $this->sourceScope = null;
+ $this->active = false;
+ }
+}
diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php
new file mode 100644
index 00000000..1b84c8c8
--- /dev/null
+++ b/src/Plugins/Tia/ResultCollector.php
@@ -0,0 +1,149 @@
+
+ */
+ private array $results = [];
+
+ private ?string $currentTestId = null;
+
+ private ?string $currentTestFile = null;
+
+ private ?float $startTime = null;
+
+ public function testPrepared(string $testId, ?string $testFile = null): void
+ {
+ $this->currentTestId = $testId;
+ $this->currentTestFile = $testFile;
+ $this->startTime = microtime(true);
+ }
+
+ public function testPassed(): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $this->record(TestStatus::success());
+ }
+
+ public function testFailed(string $message): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $this->record(TestStatus::failure($message));
+ }
+
+ public function testErrored(string $message): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $this->record(TestStatus::error($message));
+ }
+
+ public function testSkipped(string $message): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $this->record(TestStatus::skipped($message));
+ }
+
+ public function testIncomplete(string $message): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $this->record(TestStatus::incomplete($message));
+ }
+
+ public function testRisky(string $message): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $this->record(TestStatus::risky($message));
+ }
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->results;
+ }
+
+ public function recordAssertions(string $testId, int $assertions): void
+ {
+ if (isset($this->results[$testId])) {
+ $this->results[$testId]['assertions'] = $assertions;
+ }
+ }
+
+ /**
+ * @param array $results
+ */
+ public function merge(array $results): void
+ {
+ foreach ($results as $testId => $result) {
+ $this->results[$testId] = $result;
+ }
+ }
+
+ public function reset(): void
+ {
+ $this->results = [];
+ $this->currentTestId = null;
+ $this->currentTestFile = null;
+ $this->startTime = null;
+ }
+
+ public function finishTest(): void
+ {
+ $this->currentTestId = null;
+ $this->currentTestFile = null;
+ $this->startTime = null;
+ }
+
+ private function record(TestStatus $status): void
+ {
+ if ($this->currentTestId === null) {
+ return;
+ }
+
+ $time = $this->startTime !== null
+ ? round(microtime(true) - $this->startTime, 3)
+ : 0.0;
+
+ $existing = $this->results[$this->currentTestId] ?? null;
+
+ $this->results[$this->currentTestId] = [
+ 'status' => $status->asInt(),
+ 'message' => $status->message(),
+ 'time' => $time,
+ 'assertions' => $existing['assertions'] ?? 0,
+ ];
+
+ if ($this->currentTestFile !== null) {
+ $this->results[$this->currentTestId]['file'] = $this->currentTestFile;
+ }
+ }
+}
diff --git a/src/Plugins/Tia/SourceScope.php b/src/Plugins/Tia/SourceScope.php
new file mode 100644
index 00000000..325b3e12
--- /dev/null
+++ b/src/Plugins/Tia/SourceScope.php
@@ -0,0 +1,196 @@
+ */
+ private array $containsCache = [];
+
+ private const array TOP_LEVEL_NOISE = [
+ 'vendor',
+ 'node_modules',
+ '.git',
+ '.idea',
+ '.vscode',
+ '.github',
+ '.pest',
+ '.phpunit.cache',
+ '.cache',
+ ];
+
+ private const array NESTED_NOISE = [
+ 'storage/framework',
+ 'storage/logs',
+ 'bootstrap/cache',
+ ];
+
+ /**
+ * @param list $includes Absolute, normalised directory paths.
+ * @param list $excludes Absolute, normalised directory paths.
+ */
+ public function __construct(
+ private readonly array $includes,
+ private readonly array $excludes,
+ ) {}
+
+ public static function fromProjectRoot(string $projectRoot): self
+ {
+ $phpunitIncludes = [];
+ $phpunitExcludes = [];
+
+ try {
+ $source = Registry::get()->source();
+
+ foreach ($source->includeDirectories() as $dir) {
+ $phpunitIncludes[] = self::normalise($dir->path());
+ }
+
+ foreach ($source->excludeDirectories() as $dir) {
+ $phpunitExcludes[] = self::normalise($dir->path());
+ }
+ } catch (Throwable) {
+ // Registry not initialized — fall back to project-root scanning.
+ }
+
+ $rootIncludes = self::topLevelProjectDirs($projectRoot);
+
+ $includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
+ $excludes = array_values(array_unique([
+ ...$phpunitExcludes,
+ ...self::nestedNoiseDirs($projectRoot),
+ ]));
+
+ if ($includes === []) {
+ $includes = [self::normalise($projectRoot)];
+ }
+
+ return new self($includes, $excludes);
+ }
+
+ /**
+ * @return list Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
+ */
+ public static function testPaths(): array
+ {
+ try {
+ $suites = Registry::get()->testSuite();
+ } catch (Throwable) {
+ return [];
+ }
+ $out = [];
+ foreach ($suites as $suite) {
+ foreach ($suite->directories() as $directory) {
+ $out[] = self::normalise($directory->path());
+ }
+
+ foreach ($suite->files() as $file) {
+ $out[] = self::normalise($file->path());
+ }
+ }
+
+ return array_values(array_unique($out));
+ }
+
+ public function contains(string $absoluteFile): bool
+ {
+ if (isset($this->containsCache[$absoluteFile])) {
+ return $this->containsCache[$absoluteFile];
+ }
+
+ $real = @realpath($absoluteFile);
+ $candidate = $real === false ? $absoluteFile : $real;
+ $candidate = self::normalise($candidate);
+
+ foreach ($this->excludes as $excluded) {
+ if ($this->startsWithDir($candidate, $excluded)) {
+ return $this->containsCache[$absoluteFile] = false;
+ }
+ }
+
+ foreach ($this->includes as $included) {
+ if ($this->startsWithDir($candidate, $included)) {
+ return $this->containsCache[$absoluteFile] = true;
+ }
+ }
+
+ return $this->containsCache[$absoluteFile] = false;
+ }
+
+ /**
+ * @return list
+ */
+ private static function topLevelProjectDirs(string $projectRoot): array
+ {
+ $entries = @scandir($projectRoot);
+
+ if ($entries === false) {
+ return [];
+ }
+
+ $out = [];
+
+ foreach ($entries as $entry) {
+ if ($entry === '.') {
+ continue;
+ }
+ if ($entry === '..') {
+ continue;
+ }
+ if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
+ continue;
+ }
+
+ if ($entry !== '' && $entry[0] === '.') {
+ continue;
+ }
+
+ $abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
+
+ if (! is_dir($abs)) {
+ continue;
+ }
+
+ $out[] = self::normalise(@realpath($abs) ?: $abs);
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return list
+ */
+ private static function nestedNoiseDirs(string $projectRoot): array
+ {
+ $out = [];
+
+ foreach (self::NESTED_NOISE as $relative) {
+ $abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
+ $out[] = self::normalise(@realpath($abs) ?: $abs);
+ }
+
+ return $out;
+ }
+
+ private static function normalise(string $path): string
+ {
+ return rtrim($path, '/\\');
+ }
+
+ private function startsWithDir(string $candidate, string $dir): bool
+ {
+ if ($candidate === $dir) {
+ return true;
+ }
+
+ return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
+ }
+}
diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php
new file mode 100644
index 00000000..e7deeb6f
--- /dev/null
+++ b/src/Plugins/Tia/Storage.php
@@ -0,0 +1,146 @@
+ Sorted, deduped table names referenced by the
+ */
+ public static function fromSql(string $sql): array
+ {
+ $trimmed = ltrim($sql);
+
+ if ($trimmed === '') {
+ return [];
+ }
+
+ $prefix = strtolower(substr($trimmed, 0, 6));
+
+ $matched = false;
+ foreach (self::DML_PREFIXES as $dml) {
+ if (str_starts_with($prefix, $dml)) {
+ $matched = true;
+
+ break;
+ }
+ }
+
+ if (! $matched) {
+ return [];
+ }
+
+ $pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
+
+ if (preg_match_all($pattern, $sql, $matches) === false) {
+ return [];
+ }
+
+ $tables = [];
+
+ for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
+ $name = $matches[1][$i] !== ''
+ ? $matches[1][$i]
+ : ($matches[2][$i] !== ''
+ ? $matches[2][$i]
+ : ($matches[3][$i] !== ''
+ ? $matches[3][$i]
+ : $matches[4][$i]));
+ if ($name === '') {
+ continue;
+ }
+ if (self::isSchemaMeta($name)) {
+ continue;
+ }
+
+ $tables[strtolower($name)] = true;
+ }
+
+ $out = array_keys($tables);
+ sort($out);
+
+ return $out;
+ }
+
+ /**
+ * @return list Table names referenced by `Schema::` calls,
+ */
+ public static function fromMigrationSource(string $php): array
+ {
+ $tables = [];
+
+ $schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
+
+ if (preg_match_all($schemaPattern, $php, $matches) !== false) {
+ foreach ($matches[1] as $i => $primary) {
+ $tables[strtolower($primary)] = true;
+
+ $secondary = $matches[2][$i] ?? '';
+ if ($secondary !== '') {
+ $tables[strtolower($secondary)] = true;
+ }
+ }
+ }
+
+ $ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
+
+ if (preg_match_all($ddlPattern, $php, $matches) !== false) {
+ foreach ($matches[1] as $primary) {
+ $lower = strtolower($primary);
+ if (! self::isSchemaMeta($lower)) {
+ $tables[$lower] = true;
+ }
+ }
+ }
+
+ $dmlPatterns = [
+ '/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
+ '/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
+ '/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
+ '/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
+ ];
+
+ foreach ($dmlPatterns as $pattern) {
+ if (preg_match_all($pattern, $php, $matches) === false) {
+ continue;
+ }
+ foreach ($matches[1] as $name) {
+ $lower = strtolower($name);
+ if (! self::isSchemaMeta($lower)) {
+ $tables[$lower] = true;
+ }
+ }
+ }
+
+ $out = array_keys($tables);
+ sort($out);
+
+ return $out;
+ }
+
+ private static function isSchemaMeta(string $name): bool
+ {
+ $lower = strtolower($name);
+
+ return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
+ || str_starts_with($lower, 'pg_')
+ || str_starts_with($lower, 'information_schema');
+ }
+}
diff --git a/src/Plugins/Tia/TableTracker.php b/src/Plugins/Tia/TableTracker.php
new file mode 100644
index 00000000..d83e3369
--- /dev/null
+++ b/src/Plugins/Tia/TableTracker.php
@@ -0,0 +1,86 @@
+isActive()) {
+ return;
+ }
+
+ $containerClass = self::CONTAINER_CLASS;
+
+ if (! class_exists($containerClass)) {
+ return;
+ }
+
+ /** @var object $app */
+ $app = $containerClass::getInstance();
+
+ if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
+ return;
+ }
+
+ if ($app->bound(self::MARKER)) {
+ return;
+ }
+
+ if (! $app->bound('db')) {
+ return;
+ }
+
+ $app->instance(self::MARKER, true);
+
+ $listener = static function (object $query) use ($recorder): void {
+ if (! property_exists($query, 'sql')) {
+ return;
+ }
+
+ /** @var mixed $sql */
+ $sql = $query->sql;
+
+ if (! is_string($sql) || $sql === '') {
+ return;
+ }
+
+ foreach (TableExtractor::fromSql($sql) as $table) {
+ $recorder->linkTable($table);
+ }
+ };
+
+ /** @var object $db */
+ $db = $app->make('db');
+
+ if (is_callable([$db, 'listen'])) {
+ /** @var callable $listen */
+ $listen = [$db, 'listen'];
+ $listen($listener);
+
+ return;
+ }
+
+ if (! $app->bound('events')) {
+ return;
+ }
+
+ /** @var object $events */
+ $events = $app->make('events');
+
+ if (! method_exists($events, 'listen')) {
+ return;
+ }
+
+ $events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
+ }
+}
diff --git a/src/Plugins/Tia/TestPaths.php b/src/Plugins/Tia/TestPaths.php
new file mode 100644
index 00000000..23ecd7bd
--- /dev/null
+++ b/src/Plugins/Tia/TestPaths.php
@@ -0,0 +1,163 @@
+. 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
+ {
+ $directories = [];
+ $files = [];
+ $suffixes = [];
+
+ try {
+ $configuration = Registry::get();
+
+ foreach ($configuration->testSuite() as $suite) {
+ foreach ($suite->directories() as $directory) {
+ $rel = self::toRelative($directory->path(), $projectRoot);
+
+ if ($rel !== null) {
+ $directories[] = $rel;
+ }
+
+ $suffix = $directory->suffix();
+
+ if ($suffix !== '') {
+ $suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
+ }
+ }
+
+ foreach ($suite->files() as $file) {
+ $rel = self::toRelative($file->path(), $projectRoot);
+
+ if ($rel !== null) {
+ $files[] = $rel;
+ }
+ }
+ }
+
+ if ($suffixes === []) {
+ foreach ($configuration->testSuffixes() as $suffix) {
+ $suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
+ }
+ }
+ } catch (Throwable) {
+ // Registry not initialized — fall through to defaults.
+ }
+
+ if ($suffixes === []) {
+ $suffixes = ['.php'];
+ }
+
+ 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 toRelative(string $value, string $projectRoot): ?string
+ {
+ $value = trim($value);
+
+ if ($value === '') {
+ return null;
+ }
+
+ $real = @realpath($value);
+ $resolved = $real === false ? $value : $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)), '/');
+ }
+}
diff --git a/src/Plugins/Tia/WatchDefaults/Browser.php b/src/Plugins/Tia/WatchDefaults/Browser.php
new file mode 100644
index 00000000..b03d60c4
--- /dev/null
+++ b/src/Plugins/Tia/WatchDefaults/Browser.php
@@ -0,0 +1,100 @@
+
+ */
+ public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
+ {
+ $targets = [];
+
+ $candidate = $testPath.'/Browser';
+
+ if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
+ $targets[] = $candidate;
+ }
+
+ if (class_exists(BrowserTestIdentifier::class)) {
+ $repo = TestSuite::getInstance()->tests;
+
+ foreach ($repo->getFilenames() as $filename) {
+ $factory = $repo->get($filename);
+
+ if (! $factory instanceof TestCaseFactory) {
+ continue;
+ }
+
+ foreach ($factory->methods as $method) {
+ if (BrowserTestIdentifier::isBrowserTest($method)) {
+ $rel = self::fileRelative($projectRoot, $filename);
+
+ if ($rel !== null) {
+ $targets[] = $rel;
+ }
+
+ break;
+ }
+ }
+ }
+ }
+
+ return array_values(array_unique($targets));
+ }
+
+ private static function fileRelative(string $projectRoot, string $path): ?string
+ {
+ $real = @realpath($path);
+
+ if ($real === false) {
+ $real = $path;
+ }
+
+ $root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
+
+ if (! str_starts_with($real, $root)) {
+ return null;
+ }
+
+ return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
+ }
+}
diff --git a/src/Plugins/Tia/WatchDefaults/Inertia.php b/src/Plugins/Tia/WatchDefaults/Inertia.php
new file mode 100644
index 00000000..77151f3d
--- /dev/null
+++ b/src/Plugins/Tia/WatchDefaults/Inertia.php
@@ -0,0 +1,28 @@
+ [$testPath],
+ ];
+ }
+}
diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php
new file mode 100644
index 00000000..e0bf6ac3
--- /dev/null
+++ b/src/Plugins/Tia/WatchDefaults/Laravel.php
@@ -0,0 +1,41 @@
+ [$testPath],
+
+ 'storage/fixtures/**/*' => [$testPath],
+
+ 'app/** !*.php' => [$testPath],
+
+ 'resources/views/**' => [$testPath],
+
+ 'lang/**' => [$testPath],
+ 'resources/lang/**' => [$testPath],
+
+ 'vite.config.* !*.php' => [$testPath],
+ 'webpack.mix.* !*.php' => [$testPath],
+ 'tailwind.config.* !*.php' => [$testPath],
+ 'postcss.config.* !*.php' => [$testPath],
+ ];
+ }
+}
diff --git a/src/Plugins/Tia/WatchDefaults/Livewire.php b/src/Plugins/Tia/WatchDefaults/Livewire.php
new file mode 100644
index 00000000..5487a450
--- /dev/null
+++ b/src/Plugins/Tia/WatchDefaults/Livewire.php
@@ -0,0 +1,32 @@
+ [$testPath],
+ 'resources/views/components/**/*.blade.php' => [$testPath],
+ 'resources/views/pages/**/*.blade.php' => [$testPath],
+
+ 'resources/js/**/*.js' => [$testPath],
+ 'resources/js/**/*.ts' => [$testPath],
+ ];
+ }
+}
diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php
new file mode 100644
index 00000000..dbd95f48
--- /dev/null
+++ b/src/Plugins/Tia/WatchDefaults/Php.php
@@ -0,0 +1,38 @@
+ [$testPath],
+ '.env.testing' => [$testPath],
+ '.env.local' => [$testPath],
+ '.env.*.local' => [$testPath],
+
+ 'docker-compose.yml' => [$testPath],
+ 'docker-compose.yaml' => [$testPath],
+
+ 'phpunit.xml*' => [$testPath],
+
+ $testPath.'/Fixtures/**/*' => [$testPath],
+ $testPath.'/**/Fixtures/**/*' => [$testPath],
+
+ $testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
+ ];
+ }
+}
diff --git a/src/Plugins/Tia/WatchDefaults/Symfony.php b/src/Plugins/Tia/WatchDefaults/Symfony.php
new file mode 100644
index 00000000..bcbd3ddd
--- /dev/null
+++ b/src/Plugins/Tia/WatchDefaults/Symfony.php
@@ -0,0 +1,42 @@
+ [$testPath],
+ 'config/routes/** !*.php' => [$testPath],
+
+ 'migrations/**/*.php' => [$testPath],
+ 'src/Migrations/**/*.php' => [$testPath],
+
+ 'templates/** !*.php' => [$testPath],
+
+ 'translations/** !*.php' => [$testPath],
+
+ 'config/doctrine/**/*.xml' => [$testPath],
+ 'config/doctrine/**/*.yaml' => [$testPath],
+
+ 'webpack.config.js' => [$testPath],
+ 'importmap.php' => [$testPath],
+ 'assets/** !*.php' => [$testPath],
+ ];
+ }
+}
diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php
new file mode 100644
index 00000000..6b300857
--- /dev/null
+++ b/src/Plugins/Tia/WatchPatterns.php
@@ -0,0 +1,331 @@
+>
+ */
+ private const array DEFAULTS = [
+ WatchDefaults\Php::class,
+ WatchDefaults\Laravel::class,
+ WatchDefaults\Symfony::class,
+ WatchDefaults\Livewire::class,
+ WatchDefaults\Inertia::class,
+ WatchDefaults\Browser::class,
+ ];
+
+ private const array VCS_DIRS = ['.git', '.svn', '.hg'];
+
+ /**
+ * @var array> raw pattern key → list of project-relative test dirs/files
+ */
+ private array $patterns = [];
+
+ /**
+ * @var array, allowDotfiles: bool}>
+ */
+ private array $parsed = [];
+
+ private bool $enabled = false;
+
+ private bool $locally = false;
+
+ private bool $filtered = false;
+
+ private bool $baselined = false;
+
+ public function useDefaults(string $projectRoot): void
+ {
+ $testPath = TestSuite::getInstance()->testPath;
+
+ foreach (self::DEFAULTS as $class) {
+ $default = new $class;
+
+ if (! $default->applicable()) {
+ continue;
+ }
+
+ foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
+ $this->patterns[$key] = array_values(array_unique(
+ array_merge($this->patterns[$key] ?? [], $dirs),
+ ));
+ }
+ }
+ }
+
+ /**
+ * @param array $patterns pattern key → project-relative test dir/file
+ */
+ public function add(array $patterns): void
+ {
+ foreach ($patterns as $key => $dir) {
+ $this->patterns[$key] = array_values(array_unique(
+ array_merge($this->patterns[$key] ?? [], [$dir]),
+ ));
+ }
+ }
+
+ /**
+ * @param string $projectRoot Absolute path.
+ * @param array $changedFiles Project-relative paths.
+ * @return array Project-relative test dirs/files.
+ */
+ public function matchedDirectories(string $projectRoot, array $changedFiles): array
+ {
+ if ($this->patterns === []) {
+ return [];
+ }
+
+ $matched = [];
+
+ foreach ($changedFiles as $file) {
+ foreach ($this->patterns as $key => $dirs) {
+ if (! $this->keyMatches($key, $file)) {
+ continue;
+ }
+
+ foreach ($dirs as $dir) {
+ $matched[$dir] = true;
+ }
+ }
+ }
+
+ return array_keys($matched);
+ }
+
+ /**
+ * @param array $directories Project-relative dirs/files.
+ * @param array $allTestFiles Project-relative test files from graph.
+ * @return array
+ */
+ public function testsUnderDirectories(array $directories, array $allTestFiles): array
+ {
+ if ($directories === []) {
+ return [];
+ }
+
+ $affected = [];
+
+ foreach ($allTestFiles as $testFile) {
+ foreach ($directories as $target) {
+ if ($testFile === $target) {
+ $affected[] = $testFile;
+
+ break;
+ }
+
+ $prefix = rtrim($target, '/').'/';
+
+ if (str_starts_with($testFile, $prefix)) {
+ $affected[] = $testFile;
+
+ break;
+ }
+ }
+ }
+
+ return $affected;
+ }
+
+ public function markEnabled(): void
+ {
+ $this->enabled = true;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function markLocally(): void
+ {
+ $this->locally = true;
+ }
+
+ public function isLocally(): bool
+ {
+ return $this->locally;
+ }
+
+ public function markFiltered(): void
+ {
+ $this->filtered = true;
+ }
+
+ public function isFiltered(): bool
+ {
+ return $this->filtered;
+ }
+
+ public function markBaselined(): void
+ {
+ $this->baselined = true;
+ }
+
+ public function isBaselined(): bool
+ {
+ return $this->baselined;
+ }
+
+ public function reset(): void
+ {
+ $this->patterns = [];
+ $this->parsed = [];
+ $this->enabled = false;
+ $this->locally = false;
+ $this->filtered = false;
+ $this->baselined = false;
+ }
+
+ private function keyMatches(string $key, string $file): bool
+ {
+ $rule = $this->parse($key);
+
+ if (! $this->globMatches($rule['include'], $file)) {
+ return false;
+ }
+
+ $file = str_replace('\\', '/', $file);
+
+ if ($this->touchesVcs($file)) {
+ return false;
+ }
+
+ if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
+ return false;
+ }
+
+ foreach ($rule['excludes'] as $exclude) {
+ if ($this->excludeMatches($exclude, $file)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @return array{include: string, excludes: array, allowDotfiles: bool}
+ */
+ private function parse(string $key): array
+ {
+ if (isset($this->parsed[$key])) {
+ return $this->parsed[$key];
+ }
+
+ $tokens = preg_split('/\s+/', trim($key)) ?: [];
+
+ $include = '';
+ $excludes = [];
+
+ foreach ($tokens as $token) {
+ if ($token === '') {
+ continue;
+ }
+
+ if ($token[0] === '!') {
+ $excludes[] = substr($token, 1);
+
+ continue;
+ }
+
+ if ($include === '') {
+ $include = $token;
+ }
+ }
+
+ return $this->parsed[$key] = [
+ 'include' => $include,
+ 'excludes' => $excludes,
+ 'allowDotfiles' => $this->patternTargetsDotfiles($include),
+ ];
+ }
+
+ private function patternTargetsDotfiles(string $pattern): bool
+ {
+ foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
+ if ($segment !== '' && $segment[0] === '.') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function touchesVcs(string $file): bool
+ {
+ foreach (explode('/', $file) as $segment) {
+ if (in_array($segment, self::VCS_DIRS, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function touchesDotfile(string $file): bool
+ {
+ foreach (explode('/', $file) as $segment) {
+ if ($segment !== '' && $segment[0] === '.') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function excludeMatches(string $exclude, string $file): bool
+ {
+ $pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
+
+ if ($this->globMatches($pattern, $file)) {
+ return true;
+ }
+
+ return $this->globMatches($exclude, basename($file));
+ }
+
+ private function globMatches(string $pattern, string $file): bool
+ {
+ $pattern = str_replace('\\', '/', $pattern);
+ $file = str_replace('\\', '/', $file);
+
+ $regex = '';
+ $len = strlen($pattern);
+ $i = 0;
+
+ while ($i < $len) {
+ $c = $pattern[$i];
+
+ if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
+ $regex .= '.*';
+ $i += 2;
+
+ if (isset($pattern[$i]) && $pattern[$i] === '/') {
+ $i++;
+ }
+ } elseif ($c === '*') {
+ $regex .= '[^/]*';
+ $i++;
+ } elseif ($c === '?') {
+ $regex .= '[^/]';
+ $i++;
+ } else {
+ $regex .= preg_quote($c, '#');
+ $i++;
+ }
+ }
+
+ return (bool) preg_match('#^'.$regex.'$#', $file);
+ }
+}
diff --git a/src/Restarters/PcovRestarter.php b/src/Restarters/PcovRestarter.php
new file mode 100644
index 00000000..1337397a
--- /dev/null
+++ b/src/Restarters/PcovRestarter.php
@@ -0,0 +1,95 @@
+ $arguments
+ */
+ public function maybeRestart(string $projectRoot, array $arguments): void
+ {
+ if (! extension_loaded('pcov')) {
+ return;
+ }
+
+ if (getenv(self::ENV_RESTARTED) === '1') {
+ putenv(self::ENV_RESTARTED);
+ unset($_ENV[self::ENV_RESTARTED]);
+
+ return;
+ }
+
+ if (! Tia::isEnabledForRun($arguments)) {
+ return;
+ }
+
+ $desired = $this->normalise($projectRoot);
+ $current = $this->normalise((string) ini_get('pcov.directory'));
+
+ if ($current === $desired) {
+ return;
+ }
+
+ $this->restart($projectRoot, $arguments);
+ }
+
+ /**
+ * @param array $arguments
+ */
+ private function restart(string $projectRoot, array $arguments): void
+ {
+ $env = $this->inheritEnv();
+ $env[self::ENV_RESTARTED] = '1';
+
+ $command = array_merge(
+ [PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
+ array_values($arguments),
+ );
+
+ $proc = @proc_open(
+ $command,
+ [STDIN, STDOUT, STDERR],
+ $pipes,
+ null,
+ $env,
+ );
+
+ if (! is_resource($proc)) {
+ return;
+ }
+
+ $exitCode = proc_close($proc);
+
+ exit($exitCode === -1 ? 1 : $exitCode);
+ }
+
+ /**
+ * @return array
+ */
+ private function inheritEnv(): array
+ {
+ $env = [];
+
+ foreach (getenv() as $name => $value) {
+ $env[$name] = $value;
+ }
+
+ return $env;
+ }
+
+ private function normalise(string $path): string
+ {
+ return rtrim($path, '/\\');
+ }
+}
diff --git a/src/Restarters/XdebugRestarter.php b/src/Restarters/XdebugRestarter.php
new file mode 100644
index 00000000..a0db5bbe
--- /dev/null
+++ b/src/Restarters/XdebugRestarter.php
@@ -0,0 +1,113 @@
+ $arguments
+ */
+ public function maybeRestart(string $projectRoot, array $arguments): void
+ {
+ if (! class_exists(XdebugHandler::class)) {
+ return;
+ }
+
+ if (! extension_loaded('xdebug')) {
+ return;
+ }
+
+ if (! $this->xdebugIsCoverageOnly()) {
+ return;
+ }
+
+ if (! $this->runLooksDroppable($arguments, $projectRoot)) {
+ return;
+ }
+
+ (new XdebugHandler('pest'))->check();
+ }
+
+ private function xdebugIsCoverageOnly(): bool
+ {
+ if (! function_exists('xdebug_info')) {
+ return false;
+ }
+
+ $modes = @xdebug_info('mode');
+
+ if (! is_array($modes)) {
+ return false;
+ }
+
+ $modes = array_values(array_filter($modes, is_string(...)));
+
+ if ($modes === []) {
+ return true;
+ }
+
+ return $modes === ['coverage'];
+ }
+
+ /**
+ * @param array $arguments
+ */
+ private function runLooksDroppable(array $arguments, string $projectRoot): bool
+ {
+ foreach ($arguments as $value) {
+ if ($value === '--coverage'
+ || str_starts_with($value, '--coverage=')
+ || str_starts_with($value, '--coverage-')) {
+ return false;
+ }
+
+ if ($value === '--fresh') {
+ return false;
+ }
+ }
+
+ if (! Tia::isEnabledForRun($arguments)) {
+ return false;
+ }
+
+ return $this->tiaWillReplay($projectRoot);
+ }
+
+ private function tiaWillReplay(string $projectRoot): bool
+ {
+ $path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
+
+ if (! is_file($path)) {
+ return false;
+ }
+
+ $json = @file_get_contents($path);
+
+ if ($json === false) {
+ return false;
+ }
+
+ $graph = Graph::decode($json, $projectRoot);
+
+ if (! $graph instanceof Graph) {
+ return false;
+ }
+
+ return Fingerprint::structuralMatches(
+ $graph->fingerprint(),
+ Fingerprint::compute($projectRoot),
+ );
+ }
+}
diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php
new file mode 100644
index 00000000..46f92be8
--- /dev/null
+++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php
@@ -0,0 +1,32 @@
+test();
+
+ if ($test instanceof TestMethod) {
+ $this->collector->recordAssertions(
+ $test->className().'::'.$test->methodName(),
+ $event->numberOfAssertionsPerformed(),
+ );
+ }
+
+ $this->collector->finishTest();
+ }
+}
diff --git a/src/Subscribers/EnsureTiaEnds.php b/src/Subscribers/EnsureTiaEnds.php
new file mode 100644
index 00000000..5dba31f8
--- /dev/null
+++ b/src/Subscribers/EnsureTiaEnds.php
@@ -0,0 +1,22 @@
+recorder->endTest();
+ }
+}
diff --git a/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php b/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php
new file mode 100644
index 00000000..413e8956
--- /dev/null
+++ b/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php
@@ -0,0 +1,45 @@
+recorder->isActive()) {
+ return;
+ }
+
+ $test = $event->test();
+
+ if (! $test instanceof TestMethod) {
+ return;
+ }
+
+ $className = $test->className();
+
+ if (! class_exists($className, false)) {
+ return;
+ }
+
+ if (method_exists($className, '__initializeTestCase')) {
+ return;
+ }
+
+ Panic::with(new TiaRequiresPestTests($className, $test->file()));
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php
new file mode 100644
index 00000000..ecdf3833
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php
@@ -0,0 +1,22 @@
+collector->testErrored($event->throwable()->message());
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php
new file mode 100644
index 00000000..29940e0c
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php
@@ -0,0 +1,22 @@
+collector->testFailed($event->throwable()->message());
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php
new file mode 100644
index 00000000..330525e8
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php
@@ -0,0 +1,22 @@
+collector->testIncomplete($event->throwable()->message());
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php
new file mode 100644
index 00000000..09ebcc21
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php
@@ -0,0 +1,22 @@
+collector->testPassed();
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php
new file mode 100644
index 00000000..fe65f6eb
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php
@@ -0,0 +1,22 @@
+collector->testRisky($event->message());
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php
new file mode 100644
index 00000000..58de98ad
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php
@@ -0,0 +1,22 @@
+collector->testSkipped($event->message());
+ }
+}
diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php
new file mode 100644
index 00000000..90bc6582
--- /dev/null
+++ b/src/Subscribers/EnsureTiaResultsAreCollected.php
@@ -0,0 +1,27 @@
+test();
+
+ if ($test instanceof TestMethod) {
+ $this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
+ }
+ }
+}
diff --git a/src/Subscribers/EnsureTiaStarts.php b/src/Subscribers/EnsureTiaStarts.php
new file mode 100644
index 00000000..e6aca8c4
--- /dev/null
+++ b/src/Subscribers/EnsureTiaStarts.php
@@ -0,0 +1,33 @@
+recorder->isActive()) {
+ return;
+ }
+
+ $test = $event->test();
+
+ if (! $test instanceof TestMethod) {
+ return;
+ }
+
+ $this->recorder->beginTest($test->className(), $test->methodName(), $test->file());
+ }
+}
diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php
index a11b6ff2..f3968bc9 100644
--- a/src/Support/Coverage.php
+++ b/src/Support/Coverage.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Support;
use Pest\Exceptions\ShouldNotHappen;
+use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
@@ -89,6 +90,8 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
+ CoverageMerger::applyIfMarked($reportPath);
+
/** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
unlink($reportPath);
diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php
index 9872f52d..f9b32d60 100644
--- a/src/Support/StateGenerator.php
+++ b/src/Support/StateGenerator.php
@@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored;
+use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered;
@@ -40,11 +41,16 @@ final class StateGenerator
}
foreach ($testResult->testFailedEvents() as $testResultEvent) {
- $state->add(TestResult::fromPestParallelTestCase(
- $testResultEvent->test(),
- TestResult::FAIL,
- $testResultEvent->throwable()
- ));
+ if ($testResultEvent instanceof Failed) {
+ $state->add(TestResult::fromPestParallelTestCase(
+ $testResultEvent->test(),
+ TestResult::FAIL,
+ $testResultEvent->throwable()
+ ));
+ } else {
+ // @phpstan-ignore-next-line
+ $state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
+ }
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);
diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php
new file mode 100644
index 00000000..38ca9c3b
--- /dev/null
+++ b/src/TestCaseFilters/TiaTestCaseFilter.php
@@ -0,0 +1,55 @@
+ $affectedTestFiles Keys are project-relative test file paths.
+ */
+ public function __construct(
+ private string $projectRoot,
+ private Graph $graph,
+ private array $affectedTestFiles,
+ ) {}
+
+ public function accept(string $testCaseFilename): bool
+ {
+ $rel = $this->relative($testCaseFilename);
+
+ if ($rel === null) {
+ return true;
+ }
+
+ if (! $this->graph->knowsTest($rel)) {
+ return true;
+ }
+
+ return isset($this->affectedTestFiles[$rel]);
+ }
+
+ private function relative(string $path): ?string
+ {
+ $real = @realpath($path);
+
+ if ($real === false) {
+ $real = $path;
+ }
+
+ $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
+
+ if (! str_starts_with($real, $root)) {
+ return null;
+ }
+
+ return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
+ }
+}
diff --git a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap
index 5a0672f4..b9e2263b 100644
--- a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap
+++ b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap
@@ -1,5 +1,5 @@
- Pest Testing Framework 5.0.0-rc.6.
+ Pest Testing Framework 5.0.0-rc.7.
USAGE: pest [options]
diff --git a/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap
index 96fd4076..73bbe917 100644
--- a/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap
+++ b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap
@@ -1,3 +1,3 @@
- Pest Testing Framework 5.0.0-rc.6.
+ Pest Testing Framework 5.0.0-rc.7.
diff --git a/tests/.snapshots/Failure.php.inc b/tests/.snapshots/Failure.php.inc
index efd42309..5c3878fa 100644
--- a/tests/.snapshots/Failure.php.inc
+++ b/tests/.snapshots/Failure.php.inc
@@ -1,28 +1,56 @@
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
##teamcity[testCount count='8' flowId='1234']
+##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
+##teamcity[testCount count='8' flowId='1234']
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
+##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
+##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
+##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
+##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
+##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
+##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
+##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
+##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
+##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
+##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
+##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
+##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
+##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
+##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
+##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
+##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
+##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
+##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
+##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
+##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
+##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
+##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
[90mDuration:[39m [39m1.00s[39m
+
+ [90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
+ [90mDuration:[39m [39m1.00s[39m
+
diff --git a/tests/.snapshots/SuccessOnly.php.inc b/tests/.snapshots/SuccessOnly.php.inc
index b940b7b6..ca959e33 100644
--- a/tests/.snapshots/SuccessOnly.php.inc
+++ b/tests/.snapshots/SuccessOnly.php.inc
@@ -1,19 +1,38 @@
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
##teamcity[testCount count='4' flowId='1234']
+##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
+##teamcity[testCount count='4' flowId='1234']
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
+##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
+##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
+##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
+##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
+##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
+##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
+##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
+##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
+##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
+##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
+##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
+##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
+##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
[90mDuration:[39m [39m1.00s[39m
+
+ [90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
+ [90mDuration:[39m [39m1.00s[39m
+
diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt
index d30f8c00..0830b619 100644
--- a/tests/.snapshots/success.txt
+++ b/tests/.snapshots/success.txt
@@ -1,7 +1,7 @@
PASS Tests\Arch
✓ preset → php → ignoring ['Pest\Expectation', 'debug_backtrace', 'var_export', …]
- ✓ preset → strict → ignoring ['usleep']
+ ✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
✓ globals
✓ contracts
@@ -74,9 +74,9 @@
↓ is marked as todo 3
↓ shouldBeMarkedAsTodo
- WARN Tests\Features\Coverage
+ PASS Tests\Features\Coverage
✓ it has plugin
- - it adds coverage if --coverage exist → Coverage is not available
+ ✓ it adds coverage if --coverage exist
✓ it adds coverage if --min exist
✓ it generates coverage based on file input
@@ -1718,6 +1718,43 @@
PASS Tests\Unit\Plugins\Retry
✓ it orders by defects and stop on defects if when --retry is used
+ PASS Tests\Unit\Plugins\Tia\ContentHash
+ ✓ of() → it returns false when file does not exist
+ ✓ of() → it hashes an existing file
+ ✓ PHP files → it produces the same hash regardless of whitespace differences
+ ✓ PHP files → it ignores single-line comments
+ ✓ PHP files → it ignores hash-style comments
+ ✓ PHP files → it ignores multi-line comments
+ ✓ PHP files → it ignores doc comments
+ ✓ PHP files → it detects code changes
+ ✓ PHP files → it preserves whitespace inside string literals
+ ✓ PHP files → it treats variable renames as a change
+ ✓ PHP files → it falls back to a raw hash for unparseable PHP
+ ✓ PHP files → it is case-insensitive on the file extension
+ ✓ Blade files → it strips blade comments
+ ✓ Blade files → it strips multi-line blade comments
+ ✓ Blade files → it collapses whitespace
+ ✓ Blade files → it detects content changes
+ ✓ Blade files → it keeps blade directives intact
+ ✓ Blade files → it does not use the PHP tokenizer for blade files
+ ✓ JavaScript-like files → it strips line comments
+ ✓ JavaScript-like files → it strips block comments on their own lines
+ ✓ JavaScript-like files → it collapses whitespace
+ ✓ JavaScript-like files → it detects code changes
+ ✓ JavaScript-like files → it does not strip inline trailing comments
+ ✓ JavaScript-like files → it applies the same rules to .ts files
+ ✓ JavaScript-like files → it applies the same rules to .tsx files
+ ✓ JavaScript-like files → it applies the same rules to .jsx files
+ ✓ JavaScript-like files → it applies the same rules to .vue files
+ ✓ JavaScript-like files → it applies the same rules to .svelte files
+ ✓ JavaScript-like files → it applies the same rules to .mjs, .cjs, and .mts files
+ ✓ unknown extensions → it hashes the raw content for unknown extensions
+ ✓ unknown extensions → it does not normalise whitespace for unknown extensions
+ ✓ unknown extensions → it does not strip comments for unknown extensions
+ ✓ unknown extensions → it hashes files with no extension as raw content
+ ✓ output format → it returns a 32-character hex xxh128 hash
+ ✓ output format → it returns a stable hash for empty content
+
PASS Tests\Unit\Preset
✓ preset invalid name
✓ preset → myFramework
@@ -1903,4 +1940,4 @@
✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value')
- Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1296 passed (2976 assertions)
\ No newline at end of file
+ Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 34 skipped, 1332 passed (3020 assertions)
\ No newline at end of file
diff --git a/tests/Arch.php b/tests/Arch.php
index d0565216..82aa93da 100644
--- a/tests/Arch.php
+++ b/tests/Arch.php
@@ -1,15 +1,20 @@
preset()->php()->ignoring([
Expectation::class,
'debug_backtrace',
'var_export',
'xdebug_info',
+ 'xdebug_start_code_coverage',
+ 'xdebug_stop_code_coverage',
+ 'xdebug_get_code_coverage',
]);
arch()->preset()->strict()->ignoring([
+ BaselineSync::class,
'usleep',
]);
diff --git a/tests/Unit/Plugins/Tia/ContentHash.php b/tests/Unit/Plugins/Tia/ContentHash.php
new file mode 100644
index 00000000..6b235167
--- /dev/null
+++ b/tests/Unit/Plugins/Tia/ContentHash.php
@@ -0,0 +1,261 @@
+toBeFalse();
+ });
+
+ it('hashes an existing file', function () {
+ $path = tempnam(sys_get_temp_dir(), 'pest_').'.php';
+ file_put_contents($path, "toBeString()->not->toBeEmpty();
+ } finally {
+ @unlink($path);
+ }
+ });
+});
+
+describe('PHP files', function () {
+ it('produces the same hash regardless of whitespace differences', function () {
+ $a = ContentHash::ofContent('a.php', "toBe($b);
+ });
+
+ it('ignores single-line comments', function () {
+ $a = ContentHash::ofContent('a.php', "toBe($b);
+ });
+
+ it('ignores hash-style comments', function () {
+ $a = ContentHash::ofContent('a.php', "toBe($b);
+ });
+
+ it('ignores multi-line comments', function () {
+ $a = ContentHash::ofContent('a.php', "toBe($b);
+ });
+
+ it('ignores doc comments', function () {
+ $a = ContentHash::ofContent('a.php', "toBe($b);
+ });
+
+ it('detects code changes', function () {
+ $a = ContentHash::ofContent('a.php', 'not->toBe($b);
+ });
+
+ it('preserves whitespace inside string literals', function () {
+ $a = ContentHash::ofContent('a.php', "not->toBe($b);
+ });
+
+ it('treats variable renames as a change', function () {
+ $a = ContentHash::ofContent('a.php', 'not->toBe($b);
+ });
+
+ it('falls back to a raw hash for unparseable PHP', function () {
+ $hash = ContentHash::ofContent('a.php', 'not valid php at all');
+
+ expect($hash)->toBeString()->not->toBeEmpty();
+ });
+
+ it('is case-insensitive on the file extension', function () {
+ $a = ContentHash::ofContent('a.PHP', "toBe($b);
+ });
+});
+
+describe('Blade files', function () {
+ it('strips blade comments', function () {
+ $a = ContentHash::ofContent('a.blade.php', '{{-- a comment --}}Hello
');
+ $b = ContentHash::ofContent('a.blade.php', 'Hello
');
+
+ expect($a)->toBe($b);
+ });
+
+ it('strips multi-line blade comments', function () {
+ $a = ContentHash::ofContent('a.blade.php', "\n{{--\n multi\n line\n--}}\nHello\n
");
+ $b = ContentHash::ofContent('a.blade.php', ' Hello
');
+
+ expect($a)->toBe($b);
+ });
+
+ it('collapses whitespace', function () {
+ $a = ContentHash::ofContent('a.blade.php', "\n Hello\n World\n
");
+ $b = ContentHash::ofContent('a.blade.php', ' Hello World
');
+
+ expect($a)->toBe($b);
+ });
+
+ it('detects content changes', function () {
+ $a = ContentHash::ofContent('a.blade.php', 'Hello
');
+ $b = ContentHash::ofContent('a.blade.php', 'Goodbye
');
+
+ expect($a)->not->toBe($b);
+ });
+
+ it('keeps blade directives intact', function () {
+ $a = ContentHash::ofContent('a.blade.php', '@if($user)Hi @endif');
+ $b = ContentHash::ofContent('a.blade.php', '@if($user)Bye @endif');
+
+ expect($a)->not->toBe($b);
+ });
+
+ it('does not use the PHP tokenizer for blade files', function () {
+ $a = ContentHash::ofContent('a.blade.php', ' hello');
+ $b = ContentHash::ofContent('a.blade.php', ' hello');
+
+ expect($a)->not->toBe($b);
+ });
+});
+
+describe('JavaScript-like files', function () {
+ it('strips line comments', function () {
+ $a = ContentHash::ofContent('a.js', "// a comment\nconst foo = 1;");
+ $b = ContentHash::ofContent('a.js', 'const foo = 1;');
+
+ expect($a)->toBe($b);
+ });
+
+ it('strips block comments on their own lines', function () {
+ $a = ContentHash::ofContent('a.js', "/* block */\nconst foo = 1;");
+ $b = ContentHash::ofContent('a.js', 'const foo = 1;');
+
+ expect($a)->toBe($b);
+ });
+
+ it('collapses whitespace', function () {
+ $a = ContentHash::ofContent('a.js', "const foo = 1;\n\nconst bar = 2;");
+ $b = ContentHash::ofContent('a.js', 'const foo = 1; const bar = 2;');
+
+ expect($a)->toBe($b);
+ });
+
+ it('detects code changes', function () {
+ $a = ContentHash::ofContent('a.js', 'const foo = 1;');
+ $b = ContentHash::ofContent('a.js', 'const foo = 2;');
+
+ expect($a)->not->toBe($b);
+ });
+
+ it('does not strip inline trailing comments', function () {
+ $a = ContentHash::ofContent('a.js', 'const foo = 1; // inline');
+ $b = ContentHash::ofContent('a.js', 'const foo = 1;');
+
+ expect($a)->not->toBe($b);
+ });
+
+ it('applies the same rules to .ts files', function () {
+ $a = ContentHash::ofContent('a.ts', "// comment\nconst foo: number = 1;");
+ $b = ContentHash::ofContent('a.ts', 'const foo: number = 1;');
+
+ expect($a)->toBe($b);
+ });
+
+ it('applies the same rules to .tsx files', function () {
+ $a = ContentHash::ofContent('a.tsx', "// comment\nconst Foo = () => ;");
+ $b = ContentHash::ofContent('a.tsx', 'const Foo = () => ;');
+
+ expect($a)->toBe($b);
+ });
+
+ it('applies the same rules to .jsx files', function () {
+ $a = ContentHash::ofContent('a.jsx', "// comment\nconst Foo = () => ;");
+ $b = ContentHash::ofContent('a.jsx', 'const Foo = () => ;');
+
+ expect($a)->toBe($b);
+ });
+
+ it('applies the same rules to .vue files', function () {
+ $a = ContentHash::ofContent('a.vue', "");
+ $b = ContentHash::ofContent('a.vue', '');
+
+ expect($a)->toBe($b);
+ });
+
+ it('applies the same rules to .svelte files', function () {
+ $a = ContentHash::ofContent('a.svelte', "");
+ $b = ContentHash::ofContent('a.svelte', '');
+
+ expect($a)->toBe($b);
+ });
+
+ it('applies the same rules to .mjs, .cjs, and .mts files', function () {
+ foreach (['mjs', 'cjs', 'mts'] as $ext) {
+ $a = ContentHash::ofContent("a.$ext", "// comment\nexport const foo = 1;");
+ $b = ContentHash::ofContent("a.$ext", 'export const foo = 1;');
+
+ expect($a)->toBe($b);
+ }
+ });
+});
+
+describe('unknown extensions', function () {
+ it('hashes the raw content for unknown extensions', function () {
+ $a = ContentHash::ofContent('a.txt', 'hello world');
+ $b = ContentHash::ofContent('a.txt', 'hello world');
+
+ expect($a)->toBe($b);
+ });
+
+ it('does not normalise whitespace for unknown extensions', function () {
+ $a = ContentHash::ofContent('a.txt', 'hello world');
+ $b = ContentHash::ofContent('a.txt', 'hello world');
+
+ expect($a)->not->toBe($b);
+ });
+
+ it('does not strip comments for unknown extensions', function () {
+ $a = ContentHash::ofContent('a.txt', "// not a comment here\nhello");
+ $b = ContentHash::ofContent('a.txt', 'hello');
+
+ expect($a)->not->toBe($b);
+ });
+
+ it('hashes files with no extension as raw content', function () {
+ $a = ContentHash::ofContent('Makefile', "all:\n\techo hi");
+ $b = ContentHash::ofContent('Makefile', "all:\n\techo hi");
+
+ expect($a)->toBe($b);
+ });
+});
+
+describe('output format', function () {
+ it('returns a 32-character hex xxh128 hash', function () {
+ $hash = ContentHash::ofContent('a.php', 'toMatch('/^[a-f0-9]{32}$/');
+ });
+
+ it('returns a stable hash for empty content', function () {
+ $a = ContentHash::ofContent('a.php', '');
+ $b = ContentHash::ofContent('a.php', '');
+
+ expect($a)->toBe($b);
+ });
+});
diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php
index 60addb9c..59b9f8fd 100644
--- a/tests/Visual/Parallel.php
+++ b/tests/Visual/Parallel.php
@@ -16,6 +16,7 @@ $run = function () {
test('parallel', function () use ($run) {
$output = $run('--exclude-group=integration');
+ $output = implode("\n", array_slice(explode("\n", $output), -10));
if (getenv('REBUILD_SNAPSHOTS')) {
preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches);
@@ -23,13 +24,13 @@ test('parallel', function () use ($run) {
$file = file_get_contents(__FILE__);
$file = preg_replace(
'/\$expected = \'.*?\';/',
- "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2925 assertions)';",
+ "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 26 skipped, 1316 passed (2969 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
- $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2925 assertions)';
+ $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 26 skipped, 1316 passed (2969 assertions)';
expect($output)
->toContain("Tests: {$expected}")