diff --git a/docs/drupal-dependencies.md b/docs/drupal-dependencies.md
new file mode 100644
index 0000000000..9dc8aaf191
--- /dev/null
+++ b/docs/drupal-dependencies.md
@@ -0,0 +1,35 @@
+Inspecting Drupal dependencies
+==============================
+:octicons-tag-24: 13.4+
+
+These commands allow a developer or site builder to inspect the Drupal dependencies. It's similar with Composer's `why` command but acts in the Drupal realm, by showing dependencies between modules or config entities.
+
+Find module dependants
+----------------------
+
+Drupal modules are able to define other modules as dependencies, using the module's [metadata info.yml file](https://www.drupal.org/docs/develop/creating-modules/let-drupal-know-about-your-module-with-an-infoyml-file). To get all modules that depend on a given module type:
+
+ drush why:module node --type=module
+
+This will show all the _installed_ module dependents of `node` module. The results are rendered visually as a tree, making it easy to understand the nested relations. It also marks visually the circular dependencies.
+
+If you want to get the dependency tree as data, use the `--format` option. E.g., `--format=yaml` or `--format=json`.
+
+The above command only rendered the dependency tree of _installed_ modules. If you need to get the module dependants regardless whether they are installed or not, use the `--no-only-installed` option/flag:
+
+ drush why:module node --type=module --no-only-installed
+
+Config entities are able to declare [dependencies on modules](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-entity-dependencies). You can find also the config entities that depend on a given module. The following command shows all config entities depending on `node` module:
+
+ drush why:module node --type=config
+
+Dependents are also rendered as a tree, showing a nested structure. The `--format` option can be used in the same way, to get a machine-readable structure.
+
+Find config entity dependants
+-----------------------------
+
+Config entities are able also to declare [dependencies on other config entities](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-entity-dependencies). With `why:config` Drush command we can determine the config entities depending on a specific entity:
+
+ drush why:config node.type.article
+
+This will also render the results in a structured tree visualisation. Same, the `--format` option can be used to get data structured as `json`, `yaml`, etc.
diff --git a/src/Commands/core/DocsCommands.php b/src/Commands/core/DocsCommands.php
index fbc244fb51..b2b75e17f0 100644
--- a/src/Commands/core/DocsCommands.php
+++ b/src/Commands/core/DocsCommands.php
@@ -31,6 +31,7 @@ final class DocsCommands extends DrushCommands
const EXAMPLE_SYNC_VIA_HTTP = 'docs:example-sync-via-http';
const POLICY = 'docs:policy';
const DEPLOY = 'docs:deploy';
+ const DRUPAL_DEPENDENCIES = 'docs:drupal-dependencies';
/**
* README.md
@@ -218,4 +219,15 @@ public function deploy(): void
{
self::printFileTopic($this->commandData);
}
+
+ /**
+ * Inspecting Drupal dependencies.
+ */
+ #[CLI\Command(name: self::DRUPAL_DEPENDENCIES)]
+ #[CLI\Help(hidden: true)]
+ #[CLI\Topics(path: '../../../docs/drupal-dependencies.md')]
+ public function drupalDependencies(): void
+ {
+ self::printFileTopic($this->commandData);
+ }
}
diff --git a/src/Commands/core/DrupalDependenciesCommands.php b/src/Commands/core/DrupalDependenciesCommands.php
new file mode 100644
index 0000000000..2f44b1f571
--- /dev/null
+++ b/src/Commands/core/DrupalDependenciesCommands.php
@@ -0,0 +1,329 @@
+ dependent paths. Used to detect circular references.
+ */
+ private array $relation = [];
+
+ /**
+ * Computed dependent -> dependencies relations.
+ */
+ private array $dependencies = [
+ // List of module dependencies grouped by module dependent.
+ 'module-module' => [],
+ // List of config module dependencies grouped by config dependent.
+ 'config-module' => [],
+ // List of config dependencies grouped by config dependent.
+ 'config-config' => [],
+ ];
+
+ public function __construct(
+ private readonly ModuleExtensionList $moduleExtensionList,
+ private readonly array $installedModules,
+ ) {
+ parent::__construct();
+ }
+
+ public static function create(ContainerInterface $container): self
+ {
+ return new self(
+ $container->get('extension.list.module'),
+ $container->getParameter('container.modules')
+ );
+ }
+
+ #[CLI\Command(name: self::WHY_MODULE, aliases: ['wm'])]
+ #[CLI\Help(description: 'List all objects (modules, configurations) depending on a given module')]
+ #[CLI\Argument(name: 'module', description: 'The module to check dependents for')]
+ #[CLI\Option(
+ name: 'type',
+ description: 'Type of dependents: module, config',
+ suggestedValues: ['module', 'config']
+ )]
+ #[CLI\Option(
+ name: 'only-installed',
+ description: 'Only check for installed modules'
+ )]
+ #[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
+ #[CLI\Usage(
+ name: 'drush why:module node --type=module',
+ description: 'Show all installed modules depending on node module'
+ )]
+ #[CLI\Usage(
+ name: 'drush why:module node --type=module --no-only-installed',
+ description: 'Show all modules, including uninstalled, depending on node module'
+ )]
+ #[CLI\Usage(
+ name: 'drush why:module node --type=config',
+ description: 'Show all configuration entities depending on node module'
+ )]
+ #[CLI\Usage(
+ name: 'drush why:module node --type=config --format=json',
+ description: 'Return config entity dependents as JSON'
+ )]
+ #[CLI\Topics(topics: [DocsCommands::DRUPAL_DEPENDENCIES])]
+ public function dependentsOfModule(string $module, array $options = [
+ 'type' => InputOption::VALUE_REQUIRED,
+ 'only-installed' => true,
+ 'format' => '',
+ ]): string|UnstructuredData|null
+ {
+ if ($options['type'] === 'module') {
+ $this->buildDependents($this->dependencies['module-module']);
+ } else {
+ $this->scanConfigs();
+ $this
+ ->buildDependents($this->dependencies['config-module'])
+ ->buildDependents($this->dependencies['config-config']);
+ }
+
+ if (!isset($this->dependents[$module])) {
+ $this->logger()->notice(dt('No @type depends on @module', [
+ '@module' => $module,
+ '@type' => $options['type'] === 'module' ? dt('other module') : dt('config entity'),
+ ]));
+ return null;
+ }
+
+ $this->buildTree($module);
+
+ if (empty($options['format'])) {
+ return $this->drawTree($module);
+ }
+
+ return new UnstructuredData($this->tree);
+ }
+
+ #[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'why:module')]
+ public function validateDependentsOfModule(CommandData $commandData): void
+ {
+ $type = $commandData->input()->getOption('type');
+ if (empty($type)) {
+ throw new \InvalidArgumentException("The --type option is mandatory");
+ }
+ if (!in_array($type, ['module', 'config'], true)) {
+ throw new \InvalidArgumentException(
+ "The --type option can take only 'module' or 'config' as value"
+ );
+ }
+
+ $notOnlyInstalled = $commandData->input()->getOption('no-only-installed');
+ if ($notOnlyInstalled && $type === 'config') {
+ throw new \InvalidArgumentException("Cannot use --type=config together with --no-only-installed");
+ }
+
+ $module = $commandData->input()->getArgument('module');
+ if ($type === 'module') {
+ $this->dependencies['module-module'] = array_map(function (Extension $extension): array {
+ return array_map(function (string $dependencyString) {
+ return Dependency::createFromString($dependencyString)->getName();
+ }, $extension->info['dependencies']);
+ }, $this->moduleExtensionList->reset()->getList());
+
+ if (!$notOnlyInstalled) {
+ $this->dependencies['module-module'] = array_intersect_key(
+ $this->dependencies['module-module'],
+ $this->installedModules,
+ );
+ }
+ if (!isset($this->dependencies['module-module'][$module])) {
+ throw new \InvalidArgumentException(dt('Invalid @module module', [
+ '@module' => $module,
+ ]));
+ }
+ } elseif (!isset($this->installedModules[$module])) {
+ throw new \InvalidArgumentException(dt('Invalid @module module', [
+ '@module' => $module,
+ ]));
+ }
+ }
+
+ #[CLI\Command(name: self::WHY_CONFIG, aliases: ['wc'])]
+ #[CLI\Help(description: 'List all config entities depending on a given config entity')]
+ #[CLI\Argument(name: 'config', description: 'The config entity to check dependents for')]
+ #[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
+ #[CLI\Usage(
+ name: 'drush why:config node.type.article',
+ description: 'Show all config entities modules depending on node.type.article'
+ )]
+ #[CLI\Usage(
+ name: 'drush why:config node.type.article --format=yaml',
+ description: 'Return config entity dependents as YAML'
+ )]
+ #[CLI\Topics(topics: [DocsCommands::DRUPAL_DEPENDENCIES])]
+ public function dependentsOfConfig(string $config, array $options = [
+ 'format' => '',
+ ]): string|UnstructuredData|null
+ {
+ $this->scanConfigs(false);
+ $this->buildDependents($this->dependencies['config-config']);
+
+ if (!isset($this->dependents[$config])) {
+ $this->logger()->notice(dt('No other config entity depends on @config', [
+ '@config' => $config,
+ ]));
+ return null;
+ }
+
+ $this->buildTree($config);
+
+ if (empty($options['format'])) {
+ return $this->drawTree($config);
+ }
+
+ return new UnstructuredData($this->tree);
+ }
+
+ #[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: 'why:config')]
+ public function validateDependentsOfConfig(CommandData $commandData): void
+ {
+ $configName = $commandData->input()->getArgument('config');
+ $configManager = \Drupal::getContainer()->get('config.manager');
+ if (!$configManager->loadConfigEntityByName($configName)) {
+ throw new \InvalidArgumentException(dt('Invalid @config config entity', [
+ '@config' => $configName,
+ ]));
+ }
+ }
+
+ /**
+ * Builds the nested dependency tree.
+ */
+ protected function buildTree(string $dependency, array $path = []): void
+ {
+ $path[] = $dependency;
+ foreach ($this->dependents[$dependency] as $dependent) {
+ if (isset($this->relation[$dependency]) && $this->relation[$dependency] === $dependent) {
+ // This relation has been already defined on other path. We mark
+ // it as circular reference.
+ NestedArray::setValue($this->tree, [
+ ...$path,
+ ...[$dependent],
+ ], $dependent . ':' . self::CIRCULAR_REFERENCE);
+ continue;
+ }
+
+ // Save this relation to avoid infinite circular references.
+ $this->relation[$dependency] = $dependent;
+
+ if (isset($this->dependents[$dependent])) {
+ $this->buildTree($dependent, $path);
+ } else {
+ NestedArray::setValue($this->tree, [...$path, ...[$dependent]], $dependent);
+ }
+ }
+ }
+
+ /**
+ * Build the reverse the relation: dependent -> dependencies.
+ */
+ protected function buildDependents(array $dependenciesPerDependent): self
+ {
+ foreach ($dependenciesPerDependent as $dependent => $dependencies) {
+ foreach ($dependencies as $dependency) {
+ $this->dependents[$dependency][$dependent] = $dependent;
+ }
+ }
+
+ // Make dependents order predictable.
+ foreach ($this->dependents as $dependency => $dependents) {
+ ksort($this->dependents[$dependency]);
+ }
+ ksort($this->dependents);
+
+ return $this;
+ }
+
+ /**
+ * Scans all config entities and store their module and config dependencies.
+ */
+ protected function scanConfigs(bool $scanModuleDependencies = true): void
+ {
+ $entityTypeManager = \Drupal::entityTypeManager();
+ $configTypeIds = array_keys(
+ array_filter(
+ $entityTypeManager->getDefinitions(),
+ fn(EntityTypeInterface $entityType): bool => $entityType->entityClassImplements(ConfigEntityInterface::class),
+ )
+ );
+
+ foreach ($configTypeIds as $configTypeId) {
+ /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface $config */
+ foreach ($entityTypeManager->getStorage($configTypeId)->loadMultiple() as $config) {
+ $dependencies = $config->getDependencies();
+ $name = $config->getConfigDependencyName();
+ if ($scanModuleDependencies && !empty($dependencies['module'])) {
+ $this->dependencies['config-module'][$name] = $dependencies['module'];
+ }
+ if (!empty($dependencies['config'])) {
+ $this->dependencies['config-config'][$name] = $dependencies['config'];
+ }
+ }
+ }
+ }
+
+ /**
+ * Draws a visual representation of the dependency tree.
+ */
+ private function drawTree(string $dependency): string
+ {
+ $recursiveArrayIterator = new \RecursiveArrayIterator(current($this->tree));
+ $recursiveTreeIterator = new \RecursiveTreeIterator(
+ $recursiveArrayIterator,
+ RecursiveIteratorIterator::SELF_FIRST,
+ );
+ $recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_HAS_NEXT, '├─');
+ $recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_LAST, '└─');
+ $recursiveTreeIterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_HAS_NEXT, '│ ');
+ $canvas = [$dependency];
+ foreach ($recursiveTreeIterator as $row => $value) {
+ $key = $recursiveTreeIterator->getInnerIterator()->key();
+ $current = $recursiveTreeIterator->getInnerIterator()->current();
+ $label = $row;
+ if ($key . ':' . self::CIRCULAR_REFERENCE === $current) {
+ $label .= ' (' . dt('circular') . ')';
+ }
+ $canvas[] = $label;
+ }
+ return implode(PHP_EOL, $canvas);
+ }
+}
diff --git a/sut/modules/unish/drupal_dependencies/dependent1/dependent1.info.yml b/sut/modules/unish/drupal_dependencies/dependent1/dependent1.info.yml
new file mode 100644
index 0000000000..a4a352d05d
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent1/dependent1.info.yml
@@ -0,0 +1,11 @@
+name: Dependent 2
+type: module
+description: Dependent 2 module
+core_version_requirement: ^10.2 || ^11
+package: Other
+dependencies:
+ - drupal:node
+ - drupal:history
+ - drupal:taxonomy
+ - drupal:comment
+ - drupal:options
diff --git a/sut/modules/unish/drupal_dependencies/dependent2/dependent2.info.yml b/sut/modules/unish/drupal_dependencies/dependent2/dependent2.info.yml
new file mode 100644
index 0000000000..4fc96185de
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent2/dependent2.info.yml
@@ -0,0 +1,8 @@
+name: Dependent 1
+type: module
+description: Dependent 1 module
+core_version_requirement: ^10.2 || ^11
+package: Other
+dependencies:
+ - drupal:node
+ - dependent1:dependent1
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.node.vegetable.default.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.node.vegetable.default.yml
new file mode 100644
index 0000000000..f50de17ced
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.node.vegetable.default.yml
@@ -0,0 +1,46 @@
+dependencies:
+ config:
+ - field.field.node.vegetable.latin_name
+ - field.field.node.vegetable.vegetable_type
+ - node.type.vegetable
+ module:
+ - taxonomy
+id: node.vegetable.default
+targetEntityType: node
+bundle: vegetable
+mode: default
+content:
+ title:
+ type: string_textfield
+ weight: -5
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ latin_name:
+ type: string_textfield
+ weight: 0
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ vegetable_type:
+ type: options_select
+ weight: 5
+ region: content
+ settings: { }
+ third_party_settings: { }
+ created:
+ type: datetime_timestamp
+ weight: 10
+ region: content
+ settings: { }
+ third_party_settings: { }
+hidden:
+ promote: true
+ status: true
+ sticky: true
+ uid: true
+
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.taxonomy_term.vegetable_type.default.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.taxonomy_term.vegetable_type.default.yml
new file mode 100644
index 0000000000..e69ce1aa80
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_form_display.taxonomy_term.vegetable_type.default.yml
@@ -0,0 +1,32 @@
+dependencies:
+ config:
+ - taxonomy.vocabulary.vegetable_type
+ module:
+ - text
+id: taxonomy_term.vegetable_type.default
+targetEntityType: taxonomy_term
+bundle: vegetable_type
+mode: default
+content:
+ description:
+ type: text_textfield
+ weight: 0
+ region: content
+ settings: { }
+ third_party_settings: { }
+ name:
+ type: string_textfield
+ weight: -5
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 100
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+hidden: { }
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_view_display.node.vegetable.default.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_view_display.node.vegetable.default.yml
new file mode 100644
index 0000000000..7434b829c9
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/core.entity_view_display.node.vegetable.default.yml
@@ -0,0 +1,31 @@
+dependencies:
+ config:
+ - field.field.node.vegetable.latin_name
+ - field.field.node.vegetable.vegetable_type
+ - node.type.vegetable
+ module:
+ - text
+ - user
+id: node.vegetable.default
+targetEntityType: node
+bundle: vegetable
+mode: default
+content:
+ latin_name:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: 8
+ region: content
+ vegetable_type:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: -1
+ region: content
+hidden:
+ links: true
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.latin_name.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.latin_name.yml
new file mode 100644
index 0000000000..728cd66dc7
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.latin_name.yml
@@ -0,0 +1,15 @@
+dependencies:
+ config:
+ - field.storage.node.latin_name
+ - node.type.vegetable
+id: node.vegetable.latin_name
+field_name: latin_name
+entity_type: node
+bundle: vegetable
+label: Latin name
+required: true
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.vegetable_type.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.vegetable_type.yml
new file mode 100644
index 0000000000..6082ce6740
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.field.node.vegetable.vegetable_type.yml
@@ -0,0 +1,25 @@
+dependencies:
+ config:
+ - field.storage.node.vegetable_type
+ - node.type.vegetable
+ - taxonomy.vocabulary.vegetable_type
+id: node.vegetable.vegetable_type
+field_name: vegetable_type
+entity_type: node
+bundle: vegetable
+label: Vegetable type
+required: true
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:taxonomy_term'
+ handler_settings:
+ target_bundles:
+ vegetable_type: vegetable_type
+ sort:
+ field: name
+ direction: asc
+ auto_create: false
+ auto_create_bundle: ''
+field_type: entity_reference
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.latin_name.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.latin_name.yml
new file mode 100644
index 0000000000..9983f5ed24
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.latin_name.yml
@@ -0,0 +1,19 @@
+dependencies:
+ module:
+ - node
+ - taxonomy
+id: node.latin_name
+field_name: latin_name
+entity_type: node
+type: string
+settings:
+ max_length: 255
+ case_sensitive: true
+ is_ascii: false
+module: core
+locked: false
+cardinality: 1
+translatable: false
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.vegetable_type.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.vegetable_type.yml
new file mode 100644
index 0000000000..43f648a358
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/field.storage.node.vegetable_type.yml
@@ -0,0 +1,17 @@
+dependencies:
+ module:
+ - node
+ - taxonomy
+id: node.vegetable_type
+field_name: vegetable_type
+entity_type: node
+type: entity_reference
+settings:
+ target_type: taxonomy_term
+module: comment
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/node.type.vegetable.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/node.type.vegetable.yml
new file mode 100644
index 0000000000..fb7664466a
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/node.type.vegetable.yml
@@ -0,0 +1,2 @@
+name: Vegetable
+type: vegetable
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/config/install/taxonomy.vocabulary.vegetable_type.yml b/sut/modules/unish/drupal_dependencies/dependent3/config/install/taxonomy.vocabulary.vegetable_type.yml
new file mode 100644
index 0000000000..1dbafe44d6
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/config/install/taxonomy.vocabulary.vegetable_type.yml
@@ -0,0 +1,2 @@
+name: Vegetable type
+vid: vegetable_type
diff --git a/sut/modules/unish/drupal_dependencies/dependent3/dependent3.info.yml b/sut/modules/unish/drupal_dependencies/dependent3/dependent3.info.yml
new file mode 100644
index 0000000000..3a2e54eab6
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent3/dependent3.info.yml
@@ -0,0 +1,7 @@
+name: Dependent 3
+type: module
+description: Dependent 3 module
+core_version_requirement: ^10.2 || ^11
+package: Other
+dependencies:
+ - dependent2:dependent2
diff --git a/sut/modules/unish/drupal_dependencies/dependent4/dependent4.info.yml b/sut/modules/unish/drupal_dependencies/dependent4/dependent4.info.yml
new file mode 100644
index 0000000000..a5bdd452ab
--- /dev/null
+++ b/sut/modules/unish/drupal_dependencies/dependent4/dependent4.info.yml
@@ -0,0 +1,7 @@
+name: Dependent 4
+type: module
+description: Dependent 4 module
+core_version_requirement: ^10.2 || ^11
+package: Other
+dependencies:
+ - dependent2:dependent2
diff --git a/tests/integration/DrupalDependenciesTest.php b/tests/integration/DrupalDependenciesTest.php
new file mode 100644
index 0000000000..2e806b3641
--- /dev/null
+++ b/tests/integration/DrupalDependenciesTest.php
@@ -0,0 +1,267 @@
+drush('list');
+ $this->assertStringContainsString('why:module (wm)', $this->getOutput());
+ $this->assertStringContainsString('List all objects (modules, configurations)', $this->getOutput());
+ $this->assertStringContainsString('depending on a given module', $this->getOutput());
+
+ // Trying to check an uninstalled module.
+ $this->drush('why:module', ['node'], ['type' => 'module'], UnishTestCase::EXIT_ERROR);
+ $this->assertStringContainsString('Invalid node module', $this->getErrorOutput());
+
+ // Check also uninstalled modules.
+ $this->drush('wm', ['node'], [
+ 'type' => 'module',
+ 'no-only-installed' => null,
+ ]);
+
+ // In Drupal 10, book, forum, statistics and tracker modules were part
+ // of Drupal core. Ensure a backwards compatible expectation.
+ // @todo Remove the BC layer when Drupal 10 support is dropped.
+ $expected = DeprecationHelper::backwardsCompatibleCall(
+ \Drupal::VERSION,
+ '11.0.0',
+ fn() => << <<assertSame($expected, $this->getOutput());
+
+ // Install node module.
+ $this->drush('pm:install', ['node']);
+
+ // No installed dependencies.
+ $this->drush('why:module', ['node'], ['type' => 'module']);
+ $this->assertSame('[notice] No other module depends on node', $this->getErrorOutput());
+
+ $this->drush('pm:install', ['taxonomy']);
+ $this->drush('wm', ['node'], ['type' => 'module']);
+ $expected = <<assertSame($expected, $this->getOutput());
+
+ $this->drush('pm:install', ['dependent3']);
+ $this->drush('wm', ['node'], ['type' => 'module']);
+ $expected = <<assertSame($expected, $this->getOutput());
+
+ // Test result formatted as JSON.
+ $this->drush('wm', ['node'], [
+ 'type' => 'module',
+ 'format' => 'json',
+ ]);
+ $expected = [
+ 'node' => [
+ 'dependent1' => [
+ 'dependent2' => [
+ 'dependent3' => 'dependent3',
+ ],
+ ],
+ 'dependent2' => [
+ 'dependent3' => 'dependent3:***circular***',
+ ],
+ 'history' => [
+ 'dependent1' => [
+ 'dependent2' => 'dependent2:***circular***',
+ ],
+ ],
+ 'taxonomy' => [
+ 'dependent1' => [
+ 'dependent2' => 'dependent2:***circular***',
+ ],
+ ],
+ ],
+ ];
+ $this->assertSame($expected, $this->getOutputFromJSON());
+ }
+
+ /**
+ * @covers ::validateDependentsOfModule
+ */
+ public function testOptionsMismatch(): void
+ {
+ $this->drush('why:module', ['node'], [], UnishTestCase::EXIT_ERROR);
+ $this->assertStringContainsString("The --type option is mandatory", $this->getErrorOutput());
+
+ $this->drush('why:module', ['node'], ['type' => 'wrong'], UnishTestCase::EXIT_ERROR);
+ $this->assertStringContainsString(
+ "The --type option can take only 'module' or 'config' as value",
+ $this->getErrorOutput()
+ );
+
+ $this->drush('why:module', ['node'], [
+ 'type' => 'config',
+ 'no-only-installed' => null,
+ ], UnishTestCase::EXIT_ERROR);
+ $this->assertStringContainsString(
+ "Cannot use --type=config together with --no-only-installed",
+ $this->getErrorOutput()
+ );
+ }
+
+ /**
+ * @covers ::dependentsOfModule
+ */
+ public function testConfigDependentOfModule(): void
+ {
+ // Trying to check an uninstalled module.
+ $this->drush('why:module', ['node'], ['type' => 'config'], UnishTestCase::EXIT_ERROR);
+ $this->assertStringContainsString('Invalid node module', $this->getErrorOutput());
+
+ // Install node module.
+ $this->drush('pm:install', ['node']);
+
+ // No installed dependencies.
+ $this->drush('why:module', ['node'], ['type' => 'config']);
+ $expected = <<assertSame($expected, $this->getOutput());
+
+ $this->drush('pm:install', ['dependent3']);
+ $this->drush('wm', ['node'], ['type' => 'config']);
+ $expected = <<assertSame($expected, $this->getOutput());
+ }
+
+ /**
+ * @covers ::dependentsOfConfig
+ */
+ public function testConfigDependentOfConfig(): void
+ {
+ $this->drush('why:config', ['system.site'], [], UnishTestCase::EXIT_ERROR);
+ $this->assertStringContainsString('Invalid system.site config entity', $this->getErrorOutput());
+
+ // Install dependent3 module.
+ $this->drush('pm:install', ['dependent3']);
+
+ $this->drush('why:config', ['node.type.vegetable']);
+ $expected = <<assertSame($expected, $this->getOutput());
+ }
+
+ protected function tearDown(): void
+ {
+ try {
+ $this->drush('pmu', ['node,history,taxonomy,comment,dependent3'], ['yes' => null]);
+ } catch (\Exception) {
+ // The modules were not installed.
+ }
+ parent::tearDown();
+ }
+}