diff --git a/.gitattributes b/.gitattributes
index 7c9ff784df6..12e0abc1839 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,3 +11,4 @@ tests/FileFormatter/ValueObject/Fixture/composer_carriage_return_line_feed.json
# for 3rd party packages working with rector/rector-src as dependency
rules-tests export-ignore
tests export-ignore
+phpunit-tia.php export-ignore
diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php
index 9b360269026..a104d836fcb 100644
--- a/composer-dependency-analyser.php
+++ b/composer-dependency-analyser.php
@@ -12,6 +12,7 @@
->addPathToScan('bin', false)
// prepared test tooling
->ignoreErrorsOnPackage('phpunit/phpunit', [ErrorType::DEV_DEPENDENCY_IN_PROD])
+ ->ignoreErrorsOnPackage('jasonmccreary/phpunit-tia', [ErrorType::DEV_DEPENDENCY_IN_PROD])
// pinned v3.x version
->ignoreErrorsOnPackage('react/promise', [ErrorType::UNUSED_DEPENDENCY])
// ensure use version ^3.2.0
diff --git a/composer.json b/composer.json
index dd8901d5d78..5463ed743eb 100644
--- a/composer.json
+++ b/composer.json
@@ -42,6 +42,7 @@
"webmozart/assert": "^2.4"
},
"require-dev": {
+ "jasonmccreary/phpunit-tia": "^0.1.1",
"nette/robot-loader": "^4.1",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/extension-installer": "^1.4",
diff --git a/phpunit-tia.php b/phpunit-tia.php
new file mode 100644
index 00000000000..2bbd2584ed4
--- /dev/null
+++ b/phpunit-tia.php
@@ -0,0 +1,9 @@
+ [FixtureResolver::class],
+];
diff --git a/phpunit.xml b/phpunit.xml
index 37c4eb12d7f..739b4ef8f26 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -18,6 +18,13 @@
+
+
+
+
+
+
+
diff --git a/src/Testing/PHPUnit/AbstractLazyTestCase.php b/src/Testing/PHPUnit/AbstractLazyTestCase.php
index 3fc41dd2e3a..11a13553d07 100644
--- a/src/Testing/PHPUnit/AbstractLazyTestCase.php
+++ b/src/Testing/PHPUnit/AbstractLazyTestCase.php
@@ -4,6 +4,7 @@
namespace Rector\Testing\PHPUnit;
+use JMac\Testing\PhpUnit\Tia\Traits\RunWithTia;
use PHPUnit\Framework\TestCase;
use PHPUnit\Runner\Version;
use Rector\Config\RectorConfig;
@@ -11,10 +12,17 @@
abstract class AbstractLazyTestCase extends TestCase
{
+ // skips tests unaffected by the changed files, based on the recorded coverage graph
+ use RunWithTia {
+ RunWithTia::setUp as tiaSetUp;
+ }
+
protected static ?RectorConfig $rectorConfig = null;
protected function setUp(): void
{
+ $this->tiaSetUp();
+
// this is needed to have always the same preloaded nikic/php-parser classes
// in both bare AbstractLazyTestCase lazy tests and AbstractRectorTestCase tests
$this->includePreloadFilesAndScoperAutoload();
diff --git a/tests/Tia/FixtureResolver.php b/tests/Tia/FixtureResolver.php
new file mode 100644
index 00000000000..994786dc7c0
--- /dev/null
+++ b/tests/Tia/FixtureResolver.php
@@ -0,0 +1,59 @@
+
+ */
+ public function resolve(string $projectRoot, string $changedRelativePath): array
+ {
+ if (! $this->isInTestDirectory($changedRelativePath)) {
+ return [];
+ }
+
+ $rootDirectory = rtrim(str_replace('\\', '/', $projectRoot), '/');
+ $directory = dirname($rootDirectory . '/' . $changedRelativePath);
+
+ while (str_starts_with($directory . '/', $rootDirectory . '/') && $directory !== $rootDirectory) {
+ $testFilePaths = glob($directory . '/*Test.php');
+
+ if ($testFilePaths !== false && $testFilePaths !== []) {
+ return array_values($testFilePaths);
+ }
+
+ $directory = dirname($directory);
+ }
+
+ return [];
+ }
+
+ private function isInTestDirectory(string $changedRelativePath): bool
+ {
+ foreach (self::TEST_DIRECTORIES as $testDirectory) {
+ if (str_starts_with($changedRelativePath, $testDirectory)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Tia/FixtureResolverTest.php b/tests/Tia/FixtureResolverTest.php
new file mode 100644
index 00000000000..fb51bfcdd87
--- /dev/null
+++ b/tests/Tia/FixtureResolverTest.php
@@ -0,0 +1,86 @@
+projectRoot = sys_get_temp_dir() . '/rector_tia_fixture_resolver';
+
+ FileSystem::delete($this->projectRoot);
+
+ foreach ([
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/SomeRectorTest.php',
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/Fixture/some_fixture.php.inc',
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/Fixture/nested/deep_fixture.php.inc',
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/Source/SomeSource.php',
+ 'rules-tests/SomeSet/Rector/If_/WithoutTestRector/Fixture/orphan.php.inc',
+ 'src/SomeService.php',
+ ] as $relativeFilePath) {
+ FileSystem::write($this->projectRoot . '/' . $relativeFilePath, '');
+ }
+
+ $this->fixtureResolver = new FixtureResolver();
+ }
+
+ protected function tearDown(): void
+ {
+ FileSystem::delete($this->projectRoot);
+ }
+
+ /**
+ * @param string[] $expectedRelativeFilePaths
+ */
+ #[DataProvider('provideData')]
+ public function testResolve(string $changedRelativePath, array $expectedRelativeFilePaths): void
+ {
+ $resolvedFilePaths = $this->fixtureResolver->resolve($this->projectRoot, $changedRelativePath);
+
+ $expectedFilePaths = array_map(
+ fn (string $relativeFilePath): string => $this->projectRoot . '/' . $relativeFilePath,
+ $expectedRelativeFilePaths
+ );
+
+ $this->assertSame($expectedFilePaths, $resolvedFilePaths);
+ }
+
+ public static function provideData(): iterable
+ {
+ $testFilePath = 'rules-tests/SomeSet/Rector/If_/SomeRector/SomeRectorTest.php';
+
+ yield 'fixture file resolves to the sibling test' => [
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/Fixture/some_fixture.php.inc',
+ [$testFilePath],
+ ];
+
+ yield 'nested fixture file walks up to the test' => [
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/Fixture/nested/deep_fixture.php.inc',
+ [$testFilePath],
+ ];
+
+ yield 'source file resolves to the sibling test' => [
+ 'rules-tests/SomeSet/Rector/If_/SomeRector/Source/SomeSource.php',
+ [$testFilePath],
+ ];
+
+ yield 'test file itself resolves to itself' => [$testFilePath, [$testFilePath]];
+
+ yield 'fixture without any test above it resolves to nothing' => [
+ 'rules-tests/SomeSet/Rector/If_/WithoutTestRector/Fixture/orphan.php.inc',
+ [],
+ ];
+
+ yield 'file outside test directories is ignored' => ['src/SomeService.php', []];
+ }
+}