Skip to content

Commit 7bfe79b

Browse files
mglamanclaude
andauthored
Add Drupal runtime hook to bypass core_version_requirement checks (#47)
* Add Drupal runtime hook to bypass core_version_requirement checks Uses Drupal's container_service_providers global to inject a compiler pass that writes a system_info_alter hook implementation directly into the .hook_data container parameter. This allows the Drupal UI and Drush to install extensions that are in the drupal-lenient allowed list, mirroring the Composer-level constraint leniency at Drupal runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add drupal/core as dev dep, restore full PHPStan coverage Now that drupal/core can resolve alongside our dependencies (with the lock file regenerated), include src/Drupal/ in PHPStan analysis instead of excluding it. Fix the $GLOBALS mixed-offset errors by suppressing the single unanalysable line in autoload.php. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Bump minimum PHP version to 8.3 drupal/core (added as a dev dependency for static analysis of the Drupal runtime integration code) requires PHP >=8.3. Update composer.json and both CI workflow files to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Guard hook injection behind Drupal 11.3 version check The .hook_data container parameter — which this pass reads and writes — was introduced in Drupal 11.3. On earlier versions the OOP hook pipeline does not exist, so bail out before attempting to inject anything. Refs: https://git.drupalcode.org/project/drupal/-/commit/3f1bff4c Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Use dynamicConstantNames for Drupal::VERSION in PHPStan config Prevents PHPStan from evaluating Drupal::VERSION as a compile-time constant (the installed dev dep version), which was causing the 11.3 version guard to be flagged as always-false dead code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Support Drupal 11.2.x hook_implementations_map alongside 11.3+ .hook_data Replace the Drupal::VERSION check with parameter-based detection, which is more robust and naturally handles early installer builds. Two paths: - Drupal 11.3+ (.hook_data): existing approach, attributed to 'core' to bypass the installed-module check in ModuleHandler. - Drupal 11.2.x (hook_implementations_map): add to the map and register the service as a kernel.event_listener on drupal_hook.system_info_alter, using 'system' as the module name since there is no 'core' bypass in the 11.2.x ModuleHandler::getFlatHookListeners(). Also removes dynamicConstantNames from phpstan.neon since Drupal::VERSION is no longer referenced. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address Copilot review: robustness fixes in LenientHooks and LenientHookPass - Use InstalledVersions::getInstallPath() to locate the project root instead of dirname(__DIR__, 5), which breaks with custom vendor-dir. - Guard hook_list keys before writing to .hook_data (defensive init). - Validate allowed-list is an array before passing to in_array(). - Use JSON_THROW_ON_ERROR and validate decoded structure step-by-step instead of relying on null-coalescing over untyped nested access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add unit tests for LenientHookPass and LenientHooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix risky tests by adding missing @Covers for private methods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 16a2e83 commit 7bfe79b

File tree

8 files changed

+549
-6
lines changed

8 files changed

+549
-6
lines changed

.github/workflows/tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
uses: "shivammathur/setup-php@v2"
1616
with:
1717
coverage: none
18-
php-version: 8.1
18+
php-version: 8.3
1919
tools: composer:v2
2020
- name: "Install dependencies"
2121
run: "composer update --no-progress --prefer-dist"
@@ -31,7 +31,7 @@ jobs:
3131
uses: "shivammathur/setup-php@v2"
3232
with:
3333
coverage: none
34-
php-version: 8.1
34+
php-version: 8.3
3535
tools: composer:v2
3636
- name: "Install dependencies"
3737
run: "composer update --no-progress --prefer-dist"
@@ -47,7 +47,7 @@ jobs:
4747
uses: "shivammathur/setup-php@v2"
4848
with:
4949
coverage: xdebug
50-
php-version: 8.1
50+
php-version: 8.3
5151
tools: composer:v2
5252
- name: "Install dependencies"
5353
run: "composer update --no-progress --prefer-dist"

composer.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"autoload": {
66
"psr-4": {
77
"ComposerDrupalLenient\\": "src/"
8-
}
8+
},
9+
"files": [
10+
"src/autoload.php"
11+
]
912
},
1013
"authors": [
1114
{
@@ -14,14 +17,15 @@
1417
}
1518
],
1619
"require": {
17-
"php": ">=8.1",
20+
"php": ">=8.3",
1821
"composer-plugin-api": "^2.0"
1922
},
2023
"extra": {
2124
"class": "ComposerDrupalLenient\\Plugin"
2225
},
2326
"require-dev": {
2427
"composer/composer": "^2.3",
28+
"drupal/core": "^11 || ^12",
2529
"phpstan/extension-installer": "^1.1",
2630
"phpstan/phpstan": "^1.6",
2731
"phpstan/phpstan-phpunit": "^1.1",
@@ -31,7 +35,8 @@
3135
},
3236
"config": {
3337
"allow-plugins": {
34-
"phpstan/extension-installer": true
38+
"phpstan/extension-installer": true,
39+
"drupal/core-composer-scaffold": false
3540
}
3641
}
3742
}

src/Drupal/LenientHookPass.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ComposerDrupalLenient\Drupal;
6+
7+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
8+
use Symfony\Component\DependencyInjection\ContainerBuilder;
9+
10+
/**
11+
* Injects a system_info_alter hook implementation into Drupal's hook system.
12+
*
13+
* Supports two hook dispatch mechanisms depending on the Drupal version:
14+
*
15+
* Drupal 11.3+ (.hook_data parameter):
16+
* HookCollectorPass writes collected implementations to a '.hook_data'
17+
* container parameter, which HookCollectorKeyValueWritePass later persists
18+
* to the key-value store. We add our implementation directly to that
19+
* parameter, attributed to 'core' to bypass the installed-module check in
20+
* ModuleHandler::getHookImplementationList().
21+
*
22+
* Drupal 11.2.x (hook_implementations_map parameter):
23+
* HookCollectorPass writes a 'hook_implementations_map' container parameter
24+
* and registers implementations as kernel.event_listener services. We add
25+
* our entry to that map and tag our service accordingly, using 'system' as
26+
* the module name since ModuleHandler::getFlatHookListeners() requires the
27+
* module to be installed and has no 'core' bypass.
28+
*
29+
* @see \Drupal\Core\Hook\HookCollectorPass
30+
* @see \Drupal\Core\Hook\HookCollectorKeyValueWritePass
31+
* @see \Drupal\Core\Extension\ModuleHandler::getHookImplementationList
32+
* @see \Drupal\Core\Extension\ModuleHandler::getFlatHookListeners
33+
*/
34+
class LenientHookPass implements CompilerPassInterface
35+
{
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public function process(ContainerBuilder $container): void
40+
{
41+
$class = LenientHooks::class;
42+
$method = 'systemInfoAlter';
43+
44+
// Drupal 11.3+: implementations are collected into '.hook_data' by
45+
// HookCollectorPass and persisted to keyvalue by
46+
// HookCollectorKeyValueWritePass. The parameter may be absent during
47+
// early installer container builds, in which case we bail silently.
48+
// @see https://git.drupalcode.org/project/drupal/-/commit/3f1bff4c
49+
if ($container->hasParameter('.hook_data')) {
50+
$hookData = $container->getParameter('.hook_data');
51+
if (!is_array($hookData)) {
52+
return;
53+
}
54+
if (!isset($hookData['hook_list']) || !is_array($hookData['hook_list'])) {
55+
$hookData['hook_list'] = [];
56+
}
57+
$hookList = &$hookData['hook_list'];
58+
if (!isset($hookList['system_info_alter']) || !is_array($hookList['system_info_alter'])) {
59+
$hookList['system_info_alter'] = [];
60+
}
61+
$hookList['system_info_alter'][$class . '::' . $method] = 'core';
62+
$container->setParameter('.hook_data', $hookData);
63+
$this->registerService($container, $class);
64+
return;
65+
}
66+
67+
// Drupal 11.2.x: implementations are stored in 'hook_implementations_map'
68+
// and dispatched as kernel.event_listener services. The module name must
69+
// be an installed module; 'system' is always present.
70+
if ($container->hasParameter('hook_implementations_map')) {
71+
/** @var array<string, array<class-string, array<string, string>>> $map */
72+
$map = $container->getParameter('hook_implementations_map');
73+
$map['system_info_alter'][$class][$method] = 'system';
74+
$container->setParameter('hook_implementations_map', $map);
75+
$this->registerService($container, $class);
76+
$container->findDefinition($class)->addTag('kernel.event_listener', [
77+
'event' => 'drupal_hook.system_info_alter',
78+
'method' => $method,
79+
'priority' => -999,
80+
]);
81+
}
82+
}
83+
84+
private function registerService(ContainerBuilder $container, string $class): void
85+
{
86+
if (!$container->hasDefinition($class)) {
87+
$container->register($class, $class)->setAutowired(true);
88+
}
89+
}
90+
}

src/Drupal/LenientHooks.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ComposerDrupalLenient\Drupal;
6+
7+
use Composer\InstalledVersions;
8+
use Drupal\Core\Extension\Extension;
9+
10+
/**
11+
* Implements hook_system_info_alter() to bypass core_version_requirement checks.
12+
*
13+
* For any extension that appears in the project's drupal-lenient allowed list
14+
* (or when allow-all is set), core_incompatible is set to FALSE so that the
15+
* Drupal UI and Drush can install the extension regardless of its declared
16+
* core_version_requirement.
17+
*/
18+
class LenientHooks
19+
{
20+
/**
21+
* Cached lenient configuration read from the root composer.json.
22+
*
23+
* NULL means the config has not been read yet. FALSE means no config was
24+
* found. An array contains the parsed 'drupal-lenient' extra config.
25+
*
26+
* @var array<string, mixed>|false|null
27+
*/
28+
private static array|false|null $config = null;
29+
30+
/**
31+
* Implements hook_system_info_alter().
32+
*
33+
* @param array<string, mixed> $info
34+
* The extension info array from its .info.yml file.
35+
* @param \Drupal\Core\Extension\Extension $file
36+
* The extension object.
37+
* @param string $type
38+
* The type of extension ('module', 'theme', etc.).
39+
*/
40+
public function systemInfoAlter(array &$info, Extension $file, string $type): void
41+
{
42+
// Already marked compatible, nothing to do.
43+
if (($info['core_incompatible'] ?? true) === false) {
44+
return;
45+
}
46+
47+
$config = self::getLenientConfig();
48+
if ($config === false) {
49+
return;
50+
}
51+
52+
if ((bool) ($config['allow-all'] ?? false)) {
53+
$info['core_incompatible'] = false;
54+
return;
55+
}
56+
57+
$allowedList = $config['allowed-list'] ?? [];
58+
if (!is_array($allowedList)) {
59+
return;
60+
}
61+
$packageName = 'drupal/' . $file->getName();
62+
if (in_array($packageName, $allowedList, true)) {
63+
$info['core_incompatible'] = false;
64+
}
65+
}
66+
67+
/**
68+
* Returns the drupal-lenient config from the root composer.json.
69+
*
70+
* @return array<string, mixed>|false
71+
* The config array or FALSE if not found.
72+
*/
73+
private static function getLenientConfig(): array|false
74+
{
75+
if (self::$config !== null) {
76+
return self::$config;
77+
}
78+
79+
$rootDir = self::findRootDir();
80+
if ($rootDir === null) {
81+
self::$config = false;
82+
return false;
83+
}
84+
85+
$composerJsonPath = $rootDir . '/composer.json';
86+
if (!file_exists($composerJsonPath)) {
87+
self::$config = false;
88+
return false;
89+
}
90+
91+
$contents = file_get_contents($composerJsonPath);
92+
if ($contents === false) {
93+
self::$config = false;
94+
return false;
95+
}
96+
97+
try {
98+
$data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
99+
} catch (\JsonException) {
100+
self::$config = false;
101+
return false;
102+
}
103+
104+
if (!is_array($data)) {
105+
self::$config = false;
106+
return false;
107+
}
108+
109+
/** @var array<string, mixed> $data */
110+
$extra = $data['extra'] ?? null;
111+
if (!is_array($extra)) {
112+
self::$config = false;
113+
return false;
114+
}
115+
116+
$lenient = $extra['drupal-lenient'] ?? false;
117+
/** @var array<string, mixed>|false $lenientConfig */
118+
$lenientConfig = is_array($lenient) ? $lenient : false;
119+
self::$config = $lenientConfig;
120+
return self::$config;
121+
}
122+
123+
/**
124+
* Locates the Composer project root directory.
125+
*
126+
* Uses InstalledVersions to find this package's install path, then walks
127+
* up three levels ({vendor-dir}/{vendor}/{package} → root). This handles
128+
* custom vendor-dir settings correctly, unlike a fixed dirname() count
129+
* from __DIR__.
130+
*/
131+
private static function findRootDir(): ?string
132+
{
133+
$installPath = InstalledVersions::getInstallPath('mglaman/composer-drupal-lenient');
134+
if ($installPath !== null) {
135+
return dirname($installPath, 3);
136+
}
137+
138+
return null;
139+
}
140+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ComposerDrupalLenient\Drupal;
6+
7+
use Drupal\Core\DependencyInjection\ContainerBuilder;
8+
use Drupal\Core\DependencyInjection\ServiceProviderBase;
9+
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
10+
11+
/**
12+
* Registers a compiler pass to inject the system_info_alter hook implementation.
13+
*
14+
* This service provider is discovered by Drupal via the
15+
* $GLOBALS['conf']['container_service_providers'] mechanism set up in
16+
* src/autoload.php, which is loaded by Composer's autoloader.
17+
*/
18+
class LenientServiceProvider extends ServiceProviderBase
19+
{
20+
/**
21+
* {@inheritdoc}
22+
*/
23+
public function register(ContainerBuilder $container): void
24+
{
25+
// Run after HookCollectorPass (TYPE_BEFORE_OPTIMIZATION, priority 0)
26+
// but before HookCollectorKeyValueWritePass (TYPE_OPTIMIZE).
27+
$container->addCompilerPass(
28+
new LenientHookPass(),
29+
PassConfig::TYPE_BEFORE_OPTIMIZATION,
30+
-1
31+
);
32+
}
33+
}

src/autoload.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// Registers a Drupal container service provider so that this plugin can
6+
// influence the Drupal runtime check for core_version_requirement. This global
7+
// is read by DrupalKernel when building the service container.
8+
// @see \Drupal\Core\DrupalKernel::initializeContainer
9+
$GLOBALS['conf']['container_service_providers']['composer_drupal_lenient'] // @phpstan-ignore-line
10+
= \ComposerDrupalLenient\Drupal\LenientServiceProvider::class;

0 commit comments

Comments
 (0)