From 758778fe2203d008ba29271c00bae8086ba1f282 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 31 Jul 2026 21:35:42 -0400 Subject: [PATCH] chore: removes `--tia` hidden code --- bin/pest | 11 - bin/pest-tia-vite-deps.mjs | 239 --- bin/worker.php | 10 - composer.json | 7 +- .../BootPhpUnitConfiguration.php | 19 - src/Bootstrappers/BootSubscribers.php | 11 - src/Concerns/Testable.php | 67 - src/Configuration.php | 8 - src/Contracts/Restarter.php | 16 - src/Exceptions/BaselineFetchFailed.php | 54 - src/Exceptions/NoAffectedTestsFound.php | 32 - src/Exceptions/TiaRequiresPestTests.php | 46 - src/Kernel.php | 14 - src/Plugins/Tia.php | 1785 ----------------- src/Plugins/Tia/BaselineSync.php | 621 ------ src/Plugins/Tia/Bootstrapper.php | 28 - src/Plugins/Tia/ChangedFiles.php | 326 --- src/Plugins/Tia/Collectors.php | 28 - src/Plugins/Tia/Configuration.php | 75 - src/Plugins/Tia/ContentHash.php | 90 - src/Plugins/Tia/Contracts/State.php | 24 - src/Plugins/Tia/Contracts/WatchDefault.php | 18 - src/Plugins/Tia/CoverageCollector.php | 110 - src/Plugins/Tia/CoverageMerger.php | 177 -- src/Plugins/Tia/Edges/BladeEdges.php | 62 - src/Plugins/Tia/Edges/InertiaEdges.php | 131 -- src/Plugins/Tia/Enums/ReplayType.php | 35 - src/Plugins/Tia/FileState.php | 130 -- src/Plugins/Tia/Fingerprint.php | 282 --- src/Plugins/Tia/Graph.php | 1491 -------------- src/Plugins/Tia/JsModuleGraph.php | 397 ---- src/Plugins/Tia/Recorder.php | 355 ---- src/Plugins/Tia/ResultCollector.php | 149 -- src/Plugins/Tia/SourceScope.php | 196 -- src/Plugins/Tia/Storage.php | 146 -- src/Plugins/Tia/TableExtractor.php | 136 -- src/Plugins/Tia/TableTracker.php | 86 - src/Plugins/Tia/TestPaths.php | 163 -- src/Plugins/Tia/WatchDefaults/Browser.php | 100 - src/Plugins/Tia/WatchDefaults/Inertia.php | 28 - src/Plugins/Tia/WatchDefaults/Laravel.php | 41 - src/Plugins/Tia/WatchDefaults/Livewire.php | 32 - src/Plugins/Tia/WatchDefaults/Php.php | 38 - src/Plugins/Tia/WatchDefaults/Symfony.php | 42 - src/Plugins/Tia/WatchPatterns.php | 331 --- src/Restarters/PcovRestarter.php | 95 - src/Restarters/XdebugRestarter.php | 113 -- ...sureTiaAssertionsAreRecordedOnFinished.php | 32 - src/Subscribers/EnsureTiaEnds.php | 22 - .../EnsureTiaIsRunningPestTestsOnly.php | 45 - .../EnsureTiaResultIsRecordedOnErrored.php | 22 - .../EnsureTiaResultIsRecordedOnFailed.php | 22 - .../EnsureTiaResultIsRecordedOnIncomplete.php | 22 - .../EnsureTiaResultIsRecordedOnPassed.php | 22 - .../EnsureTiaResultIsRecordedOnRisky.php | 22 - .../EnsureTiaResultIsRecordedOnSkipped.php | 22 - .../EnsureTiaResultsAreCollected.php | 27 - src/Subscribers/EnsureTiaStarts.php | 33 - src/Support/Coverage.php | 3 - src/TestCaseFilters/TiaTestCaseFilter.php | 55 - tests/.snapshots/success.txt | 45 +- tests/Arch.php | 2 - tests/Unit/Plugins/Tia/ContentHash.php | 261 --- tests/Unit/Plugins/Tia/IsEnabledForRun.php | 20 - tests/Visual/Parallel.php | 4 +- 65 files changed, 7 insertions(+), 9069 deletions(-) delete mode 100644 bin/pest-tia-vite-deps.mjs delete mode 100644 src/Bootstrappers/BootPhpUnitConfiguration.php delete mode 100644 src/Contracts/Restarter.php delete mode 100644 src/Exceptions/BaselineFetchFailed.php delete mode 100644 src/Exceptions/NoAffectedTestsFound.php delete mode 100644 src/Exceptions/TiaRequiresPestTests.php delete mode 100644 src/Plugins/Tia.php delete mode 100644 src/Plugins/Tia/BaselineSync.php delete mode 100644 src/Plugins/Tia/Bootstrapper.php delete mode 100644 src/Plugins/Tia/ChangedFiles.php delete mode 100644 src/Plugins/Tia/Collectors.php delete mode 100644 src/Plugins/Tia/Configuration.php delete mode 100644 src/Plugins/Tia/ContentHash.php delete mode 100644 src/Plugins/Tia/Contracts/State.php delete mode 100644 src/Plugins/Tia/Contracts/WatchDefault.php delete mode 100644 src/Plugins/Tia/CoverageCollector.php delete mode 100644 src/Plugins/Tia/CoverageMerger.php delete mode 100644 src/Plugins/Tia/Edges/BladeEdges.php delete mode 100644 src/Plugins/Tia/Edges/InertiaEdges.php delete mode 100644 src/Plugins/Tia/Enums/ReplayType.php delete mode 100644 src/Plugins/Tia/FileState.php delete mode 100644 src/Plugins/Tia/Fingerprint.php delete mode 100644 src/Plugins/Tia/Graph.php delete mode 100644 src/Plugins/Tia/JsModuleGraph.php delete mode 100644 src/Plugins/Tia/Recorder.php delete mode 100644 src/Plugins/Tia/ResultCollector.php delete mode 100644 src/Plugins/Tia/SourceScope.php delete mode 100644 src/Plugins/Tia/Storage.php delete mode 100644 src/Plugins/Tia/TableExtractor.php delete mode 100644 src/Plugins/Tia/TableTracker.php delete mode 100644 src/Plugins/Tia/TestPaths.php delete mode 100644 src/Plugins/Tia/WatchDefaults/Browser.php delete mode 100644 src/Plugins/Tia/WatchDefaults/Inertia.php delete mode 100644 src/Plugins/Tia/WatchDefaults/Laravel.php delete mode 100644 src/Plugins/Tia/WatchDefaults/Livewire.php delete mode 100644 src/Plugins/Tia/WatchDefaults/Php.php delete mode 100644 src/Plugins/Tia/WatchDefaults/Symfony.php delete mode 100644 src/Plugins/Tia/WatchPatterns.php delete mode 100644 src/Restarters/PcovRestarter.php delete mode 100644 src/Restarters/XdebugRestarter.php delete mode 100644 src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php delete mode 100644 src/Subscribers/EnsureTiaEnds.php delete mode 100644 src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php delete mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php delete mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php delete mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php delete mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php delete mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php delete mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php delete mode 100644 src/Subscribers/EnsureTiaResultsAreCollected.php delete mode 100644 src/Subscribers/EnsureTiaStarts.php delete mode 100644 src/TestCaseFilters/TiaTestCaseFilter.php delete mode 100644 tests/Unit/Plugins/Tia/ContentHash.php delete mode 100644 tests/Unit/Plugins/Tia/IsEnabledForRun.php diff --git a/bin/pest b/bin/pest index a6f87492..9ca43c92 100755 --- a/bin/pest +++ b/bin/pest @@ -3,10 +3,8 @@ 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; @@ -195,15 +193,6 @@ 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 deleted file mode 100644 index 49133249..00000000 --- a/bin/pest-tia-vite-deps.mjs +++ /dev/null @@ -1,239 +0,0 @@ -#!/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 7b374785..dc69d67e 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -6,7 +6,6 @@ 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; @@ -59,15 +58,6 @@ $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 a7149316..87847c1b 100644 --- a/composer.json +++ b/composer.json @@ -26,12 +26,12 @@ "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", - "phpunit/phpunit": "^12.5.31", + "phpunit/phpunit": "^12.5.33", "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.31", + "phpunit/phpunit": ">12.5.33", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -59,7 +59,7 @@ ] }, "require-dev": { - "mrpunyapal/peststan": "^0.2.11", + "mrpunyapal/peststan": "^0.2.12", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", @@ -124,7 +124,6 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", - "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, diff --git a/src/Bootstrappers/BootPhpUnitConfiguration.php b/src/Bootstrappers/BootPhpUnitConfiguration.php deleted file mode 100644 index c9f80812..00000000 --- a/src/Bootstrappers/BootPhpUnitConfiguration.php +++ /dev/null @@ -1,19 +0,0 @@ -build(['pest']); - } -} diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 065a5e0f..7877b237 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -25,17 +25,6 @@ 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 b978139c..1bb828e7 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -7,18 +7,12 @@ 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; @@ -81,17 +75,6 @@ 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. */ @@ -276,35 +259,8 @@ 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) { @@ -314,13 +270,6 @@ 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. */ @@ -353,12 +302,6 @@ 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) { @@ -384,16 +327,6 @@ 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 a37871ee..4261f3ef 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -119,14 +119,6 @@ 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 deleted file mode 100644 index 95324301..00000000 --- a/src/Contracts/Restarter.php +++ /dev/null @@ -1,16 +0,0 @@ - $arguments - */ - public function maybeRestart(string $projectRoot, array $arguments): void; -} diff --git a/src/Exceptions/BaselineFetchFailed.php b/src/Exceptions/BaselineFetchFailed.php deleted file mode 100644 index 301345c2..00000000 --- a/src/Exceptions/BaselineFetchFailed.php +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 162dacc2..00000000 --- a/src/Exceptions/NoAffectedTestsFound.php +++ /dev/null @@ -1,32 +0,0 @@ -writeln([ - '', - ' INFO No affected tests found.', - '', - ]); - } - - public function exitCode(): int - { - return 0; - } -} diff --git a/src/Exceptions/TiaRequiresPestTests.php b/src/Exceptions/TiaRequiresPestTests.php deleted file mode 100644 index 71d7615a..00000000 --- a/src/Exceptions/TiaRequiresPestTests.php +++ /dev/null @@ -1,46 +0,0 @@ -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/Kernel.php b/src/Kernel.php index 68de228b..6b381c73 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -41,8 +41,6 @@ final 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, @@ -50,18 +48,6 @@ final 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. */ diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php deleted file mode 100644 index 23ad44fa..00000000 --- a/src/Plugins/Tia.php +++ /dev/null @@ -1,1785 +0,0 @@ - - */ - 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; - } - if (! $watchPatterns->isLocally()) { - return true; - } - - return ! 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((string) $arg, "$argument=")) { // @phpstan-ignore-line - 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) { - $arg = (string) $arg; // @phpstan-ignore-line - - 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 deleted file mode 100644 index 5b147883..00000000 --- a/src/Plugins/Tia/BaselineSync.php +++ /dev/null @@ -1,621 +0,0 @@ - [ - '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 deleted file mode 100644 index b26ecb43..00000000 --- a/src/Plugins/Tia/Bootstrapper.php +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 9ffb680e..00000000 --- a/src/Plugins/Tia/ChangedFiles.php +++ /dev/null @@ -1,326 +0,0 @@ - $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 deleted file mode 100644 index c182f7b7..00000000 --- a/src/Plugins/Tia/Collectors.php +++ /dev/null @@ -1,28 +0,0 @@ - */ - 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 deleted file mode 100644 index 61789801..00000000 --- a/src/Plugins/Tia/Configuration.php +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index f8538bb5..00000000 --- a/src/Plugins/Tia/ContentHash.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ - public function keysWithPrefix(string $prefix): array; -} diff --git a/src/Plugins/Tia/Contracts/WatchDefault.php b/src/Plugins/Tia/Contracts/WatchDefault.php deleted file mode 100644 index feb82de4..00000000 --- a/src/Plugins/Tia/Contracts/WatchDefault.php +++ /dev/null @@ -1,18 +0,0 @@ -> 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 deleted file mode 100644 index 1aaf34cb..00000000 --- a/src/Plugins/Tia/CoverageCollector.php +++ /dev/null @@ -1,110 +0,0 @@ - - */ - 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 deleted file mode 100644 index f64f640e..00000000 --- a/src/Plugins/Tia/CoverageMerger.php +++ /dev/null @@ -1,177 +0,0 @@ -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 deleted file mode 100644 index 1d993145..00000000 --- a/src/Plugins/Tia/Edges/BladeEdges.php +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 038c1d41..00000000 --- a/src/Plugins/Tia/Edges/InertiaEdges.php +++ /dev/null @@ -1,131 +0,0 @@ -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('#]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)#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 deleted file mode 100644 index 6b669cc6..00000000 --- a/src/Plugins/Tia/Enums/ReplayType.php +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 91dc7892..00000000 --- a/src/Plugins/Tia/FileState.php +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index fc4c4f45..00000000 --- a/src/Plugins/Tia/Fingerprint.php +++ /dev/null @@ -1,282 +0,0 @@ -, - * 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 deleted file mode 100644 index 415e5291..00000000 --- a/src/Plugins/Tia/Graph.php +++ /dev/null @@ -1,1491 +0,0 @@ - */ - 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 deleted file mode 100644 index d0e117fa..00000000 --- a/src/Plugins/Tia/JsModuleGraph.php +++ /dev/null @@ -1,397 +0,0 @@ - - */ - 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 deleted file mode 100644 index 0240aa9f..00000000 --- a/src/Plugins/Tia/Recorder.php +++ /dev/null @@ -1,355 +0,0 @@ -> */ - 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 deleted file mode 100644 index 1b84c8c8..00000000 --- a/src/Plugins/Tia/ResultCollector.php +++ /dev/null @@ -1,149 +0,0 @@ - - */ - 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 deleted file mode 100644 index 325b3e12..00000000 --- a/src/Plugins/Tia/SourceScope.php +++ /dev/null @@ -1,196 +0,0 @@ - */ - 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 deleted file mode 100644 index e7deeb6f..00000000 --- a/src/Plugins/Tia/Storage.php +++ /dev/null @@ -1,146 +0,0 @@ - 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 deleted file mode 100644 index d83e3369..00000000 --- a/src/Plugins/Tia/TableTracker.php +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 23ecd7bd..00000000 --- a/src/Plugins/Tia/TestPaths.php +++ /dev/null @@ -1,163 +0,0 @@ -. 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 deleted file mode 100644 index b03d60c4..00000000 --- a/src/Plugins/Tia/WatchDefaults/Browser.php +++ /dev/null @@ -1,100 +0,0 @@ - - */ - 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 deleted file mode 100644 index 77151f3d..00000000 --- a/src/Plugins/Tia/WatchDefaults/Inertia.php +++ /dev/null @@ -1,28 +0,0 @@ - [$testPath], - ]; - } -} diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php deleted file mode 100644 index e0bf6ac3..00000000 --- a/src/Plugins/Tia/WatchDefaults/Laravel.php +++ /dev/null @@ -1,41 +0,0 @@ - [$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 deleted file mode 100644 index 5487a450..00000000 --- a/src/Plugins/Tia/WatchDefaults/Livewire.php +++ /dev/null @@ -1,32 +0,0 @@ - [$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 deleted file mode 100644 index dbd95f48..00000000 --- a/src/Plugins/Tia/WatchDefaults/Php.php +++ /dev/null @@ -1,38 +0,0 @@ - [$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 deleted file mode 100644 index bcbd3ddd..00000000 --- a/src/Plugins/Tia/WatchDefaults/Symfony.php +++ /dev/null @@ -1,42 +0,0 @@ - [$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 deleted file mode 100644 index 6b300857..00000000 --- a/src/Plugins/Tia/WatchPatterns.php +++ /dev/null @@ -1,331 +0,0 @@ -> - */ - 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 deleted file mode 100644 index 1337397a..00000000 --- a/src/Restarters/PcovRestarter.php +++ /dev/null @@ -1,95 +0,0 @@ - $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 deleted file mode 100644 index a0db5bbe..00000000 --- a/src/Restarters/XdebugRestarter.php +++ /dev/null @@ -1,113 +0,0 @@ - $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 deleted file mode 100644 index 46f92be8..00000000 --- a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 5dba31f8..00000000 --- a/src/Subscribers/EnsureTiaEnds.php +++ /dev/null @@ -1,22 +0,0 @@ -recorder->endTest(); - } -} diff --git a/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php b/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php deleted file mode 100644 index 413e8956..00000000 --- a/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index ecdf3833..00000000 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php +++ /dev/null @@ -1,22 +0,0 @@ -collector->testErrored($event->throwable()->message()); - } -} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php deleted file mode 100644 index 29940e0c..00000000 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php +++ /dev/null @@ -1,22 +0,0 @@ -collector->testFailed($event->throwable()->message()); - } -} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php deleted file mode 100644 index 330525e8..00000000 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php +++ /dev/null @@ -1,22 +0,0 @@ -collector->testIncomplete($event->throwable()->message()); - } -} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php deleted file mode 100644 index 09ebcc21..00000000 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php +++ /dev/null @@ -1,22 +0,0 @@ -collector->testPassed(); - } -} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php deleted file mode 100644 index fe65f6eb..00000000 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php +++ /dev/null @@ -1,22 +0,0 @@ -collector->testRisky($event->message()); - } -} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php deleted file mode 100644 index 58de98ad..00000000 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php +++ /dev/null @@ -1,22 +0,0 @@ -collector->testSkipped($event->message()); - } -} diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php deleted file mode 100644 index 90bc6582..00000000 --- a/src/Subscribers/EnsureTiaResultsAreCollected.php +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index e6aca8c4..00000000 --- a/src/Subscribers/EnsureTiaStarts.php +++ /dev/null @@ -1,33 +0,0 @@ -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 370e492a..a36dd8ed 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -5,7 +5,6 @@ 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,8 +88,6 @@ 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/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php deleted file mode 100644 index 38ca9c3b..00000000 --- a/src/TestCaseFilters/TiaTestCaseFilter.php +++ /dev/null @@ -1,55 +0,0 @@ - $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/.snapshots/success.txt b/tests/.snapshots/success.txt index ec8f6d7c..ec9f3cde 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 ['Pest\Plugins\Tia\BaselineSync', 'usleep'] + ✓ preset → strict → ignoring ['usleep'] ✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …] ✓ globals @@ -1720,47 +1720,6 @@ 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\Plugins\Tia\IsEnabledForRun - ✓ does not throw when an integer --random-order-seed is passed as a separate argv element - ✓ still detects --tia when an integer argument is present - PASS Tests\Unit\Preset ✓ preset invalid name ✓ preset → myFramework @@ -1946,4 +1905,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, 1333 passed (3020 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1296 passed (2979 assertions) \ No newline at end of file diff --git a/tests/Arch.php b/tests/Arch.php index f69c58df..bd285be1 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -1,7 +1,6 @@ preset()->php()->ignoring([ Expectation::class, @@ -14,7 +13,6 @@ arch()->preset()->php()->ignoring([ ]); arch()->preset()->strict()->ignoring([ - BaselineSync::class, 'usleep', ]); diff --git a/tests/Unit/Plugins/Tia/ContentHash.php b/tests/Unit/Plugins/Tia/ContentHash.php deleted file mode 100644 index 6b235167..00000000 --- a/tests/Unit/Plugins/Tia/ContentHash.php +++ /dev/null @@ -1,261 +0,0 @@ -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/Unit/Plugins/Tia/IsEnabledForRun.php b/tests/Unit/Plugins/Tia/IsEnabledForRun.php deleted file mode 100644 index cbcf6284..00000000 --- a/tests/Unit/Plugins/Tia/IsEnabledForRun.php +++ /dev/null @@ -1,20 +0,0 @@ -toBeFalse(); -}); - -test('still detects --tia when an integer argument is present', function () { - $arguments = ['--tia', '--random-order-seed', 1782350398]; - - expect(Tia::isEnabledForRun($arguments))->toBeTrue(); -}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index f8beea96..2032db37 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -24,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, 1317 passed (2969 assertions)';", + "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2928 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1317 passed (2969 assertions)'; + $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2928 assertions)'; expect($output) ->toContain("Tests: {$expected}")