diff --git a/CHANGELOG.md b/CHANGELOG.md
index d352b74..daf0f18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
All Notable changes to the **Quality Assurance - Drupal** package.
+## [5.0.3]
+
+### Fixed
+
+- Select the PHPUnit 11 or 12 configuration from the consumer project's
+ Composer metadata instead of the PHPUnit version bundled in GrumPHP.
+- Preserve symlinked extension discovery while preventing recursive traversal
+ through directory-link cycles.
+- Bound PHPUnit source exclusions to extension source paths so coverage
+ mapping no longer traverses Composer dependencies or follows recursive
+ repository links.
+
+### Changed
+
+- Require QA PHP 3.1.1 or later so generated PHPStan configurations include
+ the GrumPHP API stubs required by the shared event listener.
+
## [5.0.2]
### Fixed
@@ -193,6 +210,7 @@ Older releases (2.x.x) see [CHANGELOG-2.x.md](CHANGELOG-1.x.md).
Older releases (1.x.x) see [CHANGELOG-1.x.md](CHANGELOG-1.x.md).
+[5.0.3]: https://github.com/district09/php_package_qa-drupal/compare/5.0.2...5.0.3
[5.0.2]: https://github.com/district09/php_package_qa-drupal/compare/5.0.1...5.0.2
[5.0.1]: https://github.com/district09/php_package_qa-drupal/compare/5.0.0...5.0.1
[5.0.0]: https://github.com/district09/php_package_qa-drupal/compare/4.1.4...5.0.0
diff --git a/composer.json b/composer.json
index 7165d45..481e89d 100644
--- a/composer.json
+++ b/composer.json
@@ -29,7 +29,7 @@
"php": "^8.3",
"behat/mink": "^1.13",
"behat/mink-browserkit-driver": "^2.3",
- "district09/qa-php": "^3.0.3",
+ "district09/qa-php": "^3.1.1",
"drupal/coder": "^9.0",
"drupal/core": "^11.4 || ^12.0",
"friendsoftwig/twigcs": "^6.5",
diff --git a/configs/phpunit-extension-11.xml b/configs/phpunit-extension-11.xml
index 07903fc..c93c8cf 100644
--- a/configs/phpunit-extension-11.xml
+++ b/configs/phpunit-extension-11.xml
@@ -44,12 +44,18 @@
./modules/**/src
- ./
- ./
- ./
- ./
- ./
- ./
+ ./src
+ ./src
+ ./src
+ ./src
+ ./src
+ ./src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
diff --git a/configs/phpunit-extension.xml b/configs/phpunit-extension.xml
index ceb747a..b61f244 100644
--- a/configs/phpunit-extension.xml
+++ b/configs/phpunit-extension.xml
@@ -44,12 +44,18 @@
./modules/**/src
- ./
- ./
- ./
- ./
- ./
- ./
+ ./src
+ ./src
+ ./src
+ ./src
+ ./src
+ ./src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
+ ./modules/**/src
diff --git a/configs/phpunit-site-11.xml b/configs/phpunit-site-11.xml
index 2d705b2..2623845 100644
--- a/configs/phpunit-site-11.xml
+++ b/configs/phpunit-site-11.xml
@@ -67,12 +67,27 @@
web/themes/custom
- ./
- ./
- ./
- ./
- ./
- ./
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom/**/tests
+ web/profiles/custom/**/tests
+ web/themes/custom/**/tests
web/themes/custom/*/source
diff --git a/configs/phpunit-site.xml b/configs/phpunit-site.xml
index 50c8a52..bf84f8f 100644
--- a/configs/phpunit-site.xml
+++ b/configs/phpunit-site.xml
@@ -67,12 +67,27 @@
web/themes/custom
- ./
- ./
- ./
- ./
- ./
- ./
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom
+ web/profiles/custom
+ web/themes/custom
+ web/modules/custom/**/tests
+ web/profiles/custom/**/tests
+ web/themes/custom/**/tests
web/themes/custom/*/source
diff --git a/src/GrumPHP/ConfigFileMerger.php b/src/GrumPHP/ConfigFileMerger.php
index 84658d1..633ec32 100644
--- a/src/GrumPHP/ConfigFileMerger.php
+++ b/src/GrumPHP/ConfigFileMerger.php
@@ -11,7 +11,6 @@
use GrumPHP\Task\Phpunit;
use GrumPHP\Task\TaskInterface;
use Nette\Neon\Neon;
-use PHPUnit\Runner\Version;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;
@@ -35,6 +34,14 @@ final class ConfigFileMerger
private const FILETYPE_YAML = 'yaml';
private const FILETYPE_NEON = 'neon';
+ /**
+ * PHPUnit configuration suffixes by supported runner major.
+ */
+ private const PHPUNIT_TEMPLATE_SUFFIXES = [
+ 11 => '-11',
+ 12 => '',
+ ];
+
/**
* Mapping of task types and their configuration files.
*
@@ -179,8 +186,8 @@ private function getConfigFileCandidates(
$packageTypeFilename = $taskInfo['filename'] . '-' . $type;
// PHPUnit configuration schemas are specific to the runner major.
- if ($taskInfo['filename'] === 'phpunit' && Version::majorVersionNumber() === 11) {
- $packageTypeFilename .= '-11';
+ if ($taskInfo['filename'] === 'phpunit') {
+ $packageTypeFilename .= self::PHPUNIT_TEMPLATE_SUFFIXES[PhpunitVersionResolver::installedMajorVersion()];
}
return [
diff --git a/src/GrumPHP/PhpunitVersionResolver.php b/src/GrumPHP/PhpunitVersionResolver.php
new file mode 100644
index 0000000..9ca14d0
--- /dev/null
+++ b/src/GrumPHP/PhpunitVersionResolver.php
@@ -0,0 +1,43 @@
+\d+)/', $version, $matches)) {
+ throw new \LogicException(sprintf(
+ 'Unable to determine the PHPUnit major version from "%s".',
+ $version,
+ ));
+ }
+
+ $major = (int) $matches['major'];
+ if (!in_array($major, [11, 12], TRUE)) {
+ throw new \LogicException(sprintf(
+ 'Unsupported PHPUnit major version %d. Supported versions are 11 and 12.',
+ $major,
+ ));
+ }
+
+ return $major;
+ }
+
+}
diff --git a/src/PHPUnit/Extension/bootstrap.php b/src/PHPUnit/Extension/bootstrap.php
index 5299b79..03e63c8 100644
--- a/src/PHPUnit/Extension/bootstrap.php
+++ b/src/PHPUnit/Extension/bootstrap.php
@@ -71,14 +71,33 @@ function drupal_phpunit_core_dir(): string {
*/
function drupal_phpunit_find_extension_directories(string $scanDirectory): array {
$extensions = [];
+ $visitedDirectories = [];
+ $realScanDirectory = realpath($scanDirectory);
+ if ($realScanDirectory !== FALSE) {
+ $visitedDirectories[$realScanDirectory] = TRUE;
+ }
+
$iterator = new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator(
$scanDirectory,
RecursiveDirectoryIterator::FOLLOW_SYMLINKS | RecursiveDirectoryIterator::SKIP_DOTS
),
- static function (SplFileInfo $file): bool {
- return !$file->isDir()
- || !in_array($file->getFilename(), ['.git', 'node_modules', 'vendor'], TRUE);
+ static function (SplFileInfo $file) use (&$visitedDirectories): bool {
+ if (!$file->isDir()) {
+ return TRUE;
+ }
+
+ if (in_array($file->getFilename(), ['.git', 'node_modules', 'vendor'], TRUE)) {
+ return FALSE;
+ }
+
+ $realPath = $file->getRealPath();
+ if ($realPath === FALSE || isset($visitedDirectories[$realPath])) {
+ return FALSE;
+ }
+
+ $visitedDirectories[$realPath] = TRUE;
+ return TRUE;
}
);
$directories = new RecursiveIteratorIterator(
diff --git a/tests/src/Compatibility/BootstrapCompatibilityTest.php b/tests/src/Compatibility/BootstrapCompatibilityTest.php
index 94bdead..1f99d0f 100644
--- a/tests/src/Compatibility/BootstrapCompatibilityTest.php
+++ b/tests/src/Compatibility/BootstrapCompatibilityTest.php
@@ -20,4 +20,62 @@ public function testDrupalTestNamespacesAreAvailable(): void {
self::assertTrue(class_exists('Behat\\Mink\\Element\\DocumentElement'));
}
+ /**
+ * Tests that extension discovery follows symlinked extension directories.
+ */
+ public function testExtensionDiscoveryFollowsDirectorySymlinks(): void {
+ $directory = sys_get_temp_dir() . '/qa-drupal-bootstrap-' . bin2hex(random_bytes(8));
+ $extensionDirectory = $directory . '-extension';
+ mkdir($directory, 0777, TRUE);
+ mkdir($extensionDirectory, 0777, TRUE);
+ file_put_contents($extensionDirectory . '/example.info.yml', "name: Example\ntype: module\n");
+
+ if (!symlink($extensionDirectory, $directory . '/example')) {
+ unlink($extensionDirectory . '/example.info.yml');
+ rmdir($extensionDirectory);
+ rmdir($directory);
+ self::markTestSkipped('Directory symlinks are not supported.');
+ }
+
+ try {
+ $extensions = drupal_phpunit_find_extension_directories($directory);
+ self::assertSame(realpath($extensionDirectory), $extensions['example']);
+ }
+ finally {
+ unlink($directory . '/example');
+ unlink($extensionDirectory . '/example.info.yml');
+ rmdir($extensionDirectory);
+ rmdir($directory);
+ }
+ }
+
+ /**
+ * Tests that extension discovery skips directory symlink cycles.
+ */
+ public function testExtensionDiscoverySkipsDirectorySymlinkCycles(): void {
+ $directory = sys_get_temp_dir() . '/qa-drupal-bootstrap-' . bin2hex(random_bytes(8));
+ $extensionDirectory = $directory . '/example';
+ mkdir($extensionDirectory, 0777, TRUE);
+ file_put_contents($extensionDirectory . '/example.info.yml', "name: Example\ntype: module\n");
+
+ if (!symlink($directory, $extensionDirectory . '/loop')) {
+ unlink($extensionDirectory . '/example.info.yml');
+ rmdir($extensionDirectory);
+ rmdir($directory);
+ self::markTestSkipped('Directory symlinks are not supported.');
+ }
+
+ try {
+ $extensions = drupal_phpunit_find_extension_directories($directory);
+ self::assertSame(realpath($extensionDirectory), $extensions['example']);
+ self::assertCount(1, $extensions);
+ }
+ finally {
+ unlink($extensionDirectory . '/loop');
+ unlink($extensionDirectory . '/example.info.yml');
+ rmdir($extensionDirectory);
+ rmdir($directory);
+ }
+ }
+
}
diff --git a/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php b/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php
index 8d41d82..d9a8321 100644
--- a/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php
+++ b/tests/src/Unit/GrumPHP/ConfigFileMergerTest.php
@@ -5,11 +5,11 @@
namespace Digipolisgent\QA\Drupal\Tests\Unit\GrumPHP;
use Digipolisgent\QA\Drupal\GrumPHP\ConfigFileMerger;
+use Digipolisgent\QA\Drupal\GrumPHP\PhpunitVersionResolver;
use GrumPHP\Task\PhpStan;
use GrumPHP\Task\Phpunit;
use Nette\Neon\Neon;
use PHPUnit\Framework\TestCase;
-use PHPUnit\Runner\Version;
/**
* Tests merging project-specific GrumPHP task configuration.
@@ -103,7 +103,7 @@ public function testPhpunitConfigurationUsesCurrentRunnerSchema(): void {
(new ConfigFileMerger())->mergeTaskConfig($task, TRUE);
$configuration = (string) file_get_contents('phpunit.qa-drupal.xml');
- $schemaVersion = Version::majorVersionNumber() === 11 ? '11.5' : '12.5';
+ $schemaVersion = PhpunitVersionResolver::installedMajorVersion() === 11 ? '11.5' : '12.5';
self::assertStringContainsString(
"https://schema.phpunit.de/$schemaVersion/phpunit.xsd",
$configuration
@@ -111,7 +111,17 @@ public function testPhpunitConfigurationUsesCurrentRunnerSchema(): void {
}
/**
- * Tests that extension test discovery is limited to project code.
+ * Tests that every supported PHPUnit major has a configuration template.
+ */
+ public function testPhpunitTemplateSuffixesCoverSupportedRunners(): void {
+ $suffixes = (new \ReflectionClass(ConfigFileMerger::class))
+ ->getConstant('PHPUNIT_TEMPLATE_SUFFIXES');
+
+ self::assertSame([11 => '-11', 12 => ''], $suffixes);
+ }
+
+ /**
+ * Tests that extension coverage stays within project source paths.
*/
public function testExtensionPhpunitConfigurationExcludesVendorPaths(): void {
$task = (new \ReflectionClass(Phpunit::class))->newInstanceWithoutConstructor();
@@ -123,6 +133,23 @@ public function testExtensionPhpunitConfigurationExcludesVendorPaths(): void {
self::assertStringContainsString('tests/src', $configuration);
self::assertStringNotContainsString('tests/src/Kernel', $configuration);
self::assertStringNotContainsString('./**/src', $configuration);
+ self::assertStringNotContainsString('>./', $configuration);
+ self::assertStringContainsString('./src', $configuration);
+ self::assertStringContainsString('./modules/**/src', $configuration);
+ }
+
+ /**
+ * Tests that site source exclusions remain inside custom extension paths.
+ */
+ public function testSitePhpunitConfigurationExcludesVendorPaths(): void {
+ $task = (new \ReflectionClass(Phpunit::class))->newInstanceWithoutConstructor();
+ (new ConfigFileMerger())->mergeTaskConfig($task, FALSE);
+
+ $configuration = (string) file_get_contents('phpunit.qa-drupal.xml');
+ self::assertStringContainsString('web/modules/custom/**/tests', $configuration);
+ self::assertStringNotContainsString('>./', $configuration);
+ self::assertStringContainsString('web/themes/custom', $configuration);
+ self::assertStringContainsString('web/profiles/custom', $configuration);
}
/**
diff --git a/tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php b/tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php
new file mode 100644
index 0000000..5c1fff4
--- /dev/null
+++ b/tests/src/Unit/GrumPHP/PhpunitVersionResolverTest.php
@@ -0,0 +1,26 @@
+