From 18b6464316a27a9212fe7d6e28cb0c83ec3e41e1 Mon Sep 17 00:00:00 2001
From: Tim Plunkett <git@plnktt.com>
Date: Wed, 22 Jan 2025 16:32:32 -0500
Subject: [PATCH] Move to level 7

---
 phpstan.neon                                  | 21 ++++++++-
 src/Controller/InstallerController.php        |  4 ++
 .../ProjectBrowserEndpointController.php      |  1 +
 src/Element/ProjectBrowser.php                |  5 +-
 src/Form/SettingsForm.php                     | 12 +++--
 .../ProjectBrowserSource/DrupalCore.php       |  2 +-
 .../DrupalDotOrgJsonApi.php                   | 18 ++++----
 src/Plugin/ProjectBrowserSource/Recipes.php   |  5 +-
 src/Plugin/ProjectBrowserSourceBase.php       |  9 ++++
 src/Plugin/ProjectBrowserSourceInterface.php  |  5 ++
 src/Plugin/ProjectBrowserSourceManager.php    | 12 +++++
 src/ProjectBrowserServiceProvider.php         |  1 +
 src/RecipeActivator.php                       |  2 +-
 .../project_browser_test.install              |  4 +-
 .../src/Datetime/TestTime.php                 |  4 +-
 .../src/DrupalOrgClientMiddleware.php         | 14 +++---
 .../ProjectBrowserTestMock.php                |  8 ++--
 .../Functional/EnabledSourceHandlerTest.php   |  2 +
 .../Functional/InstallerControllerTest.php    | 46 +++++++++++++------
 .../ProjectBrowserInstallerUiTest.php         | 14 ++++--
 .../ProjectBrowserUiTest.php                  |  4 +-
 .../src/Kernel/CoreExperimentalLabelTest.php  |  1 +
 .../Kernel/CoreNotUpdatedValidatorTest.php    |  1 +
 tests/src/Kernel/InstallerTest.php            |  8 ++--
 .../PackageNotInstalledValidatorTest.php      |  1 +
 tests/src/Kernel/RecipeActivatorTest.php      |  1 +
 tests/src/Kernel/RecipesSourceTest.php        | 10 ++--
 27 files changed, 157 insertions(+), 58 deletions(-)

diff --git a/phpstan.neon b/phpstan.neon
index a95d2402b..8676e942c 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,5 +1,5 @@
 parameters:
-  level: 6
+  level: 7
   universalObjectCratesClasses:
     - Drupal\Core\Extension\Extension
   reportUnmatchedIgnoredErrors: true
@@ -37,6 +37,13 @@ parameters:
       paths:
         - tests/src/Kernel/DatabaseTablesTest.php
       reportUnmatched: false
+    # Caused by \Drupal\Tests\user\Traits\UserCreationTrait::createUser() returning FALSE instead of throwing an exception.
+    -
+      message: "#^Parameter \\#1 \\$account of method Drupal\\\\Tests\\\\BrowserTestBase\\:\\:drupalLogin\\(\\) expects Drupal\\\\Core\\\\Session\\\\AccountInterface, Drupal\\\\user\\\\Entity\\\\User\\|false given\\.$#"
+      paths:
+        - tests/src/Functional
+        - tests/src/FunctionalJavascript
+      reportUnmatched: false
 
     ### Package Manager
     # Caused by using self instead of static as a return type in \Drupal\fixture_manipulator\FixtureManipulator.
@@ -47,6 +54,18 @@ parameters:
         - tests/src/Kernel/CoreNotUpdatedValidatorTest.php
         - tests/src/Kernel/PackageNotInstalledValidatorTest.php
       reportUnmatched: false
+    # Caused by missing return type on \Drupal\Tests\package_manager\Traits\FixtureManipulatorTrait::getStageFixtureManipulator().
+    -
+      message: "#^Call to an undefined method object\\:\\:setCorePackageVersion\\(\\)\\.$#"
+      paths:
+        - tests/src/Kernel/CoreNotUpdatedValidatorTest.php
+      reportUnmatched: false
+    # Caused by missing @throws on \Drupal\package_manager\StageBase::apply().
+    -
+      message: "#^Dead catch \\- Drupal\\\\package_manager\\\\Exception\\\\StageEventException is never thrown in the try block\\.$#"
+      paths:
+        - tests/src/Kernel/CoreNotUpdatedValidatorTest.php
+      reportUnmatched: false
 
     # @todo: Remove the following rules when support is dropped for Drupal 10.2, which does not have recipes.
     -
diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 5b4279b98..78ba9e795 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -67,6 +67,10 @@ final class InstallerController extends ControllerBase {
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container): static {
+    // @see \Drupal\project_browser\ProjectBrowserServiceProvider.
+    assert($container->get(Installer::class) instanceof Installer);
+    assert($container->get(InstallState::class) instanceof InstallState);
+
     return new static(
       $container->get(Installer::class),
       $container->get(EnabledSourceHandler::class),
diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index 9c07efdf9..ad6666740 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -47,6 +47,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
   public function getAllProjects(Request $request): JsonResponse {
     $id = $request->query->get('id');
     if ($id) {
+      assert(is_string($id));
       return new JsonResponse($this->enabledSource->getStoredProject($id));
     }
 
diff --git a/src/Element/ProjectBrowser.php b/src/Element/ProjectBrowser.php
index eff2aa0c0..b540544a7 100644
--- a/src/Element/ProjectBrowser.php
+++ b/src/Element/ProjectBrowser.php
@@ -51,10 +51,13 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    $install_readiness = $container->get(InstallReadiness::class, ContainerInterface::NULL_ON_INVALID_REFERENCE);
+    assert(is_null($install_readiness) || $install_readiness instanceof InstallReadiness);
+
     return new static(
       $plugin_id,
       $plugin_definition,
-      $container->get(InstallReadiness::class, ContainerInterface::NULL_ON_INVALID_REFERENCE),
+      $install_readiness,
       $container->get(ModuleHandlerInterface::class),
       $container->get(ConfigFactoryInterface::class),
     );
diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php
index 165a6ee55..dfce29d58 100644
--- a/src/Form/SettingsForm.php
+++ b/src/Form/SettingsForm.php
@@ -109,6 +109,7 @@ final class SettingsForm extends ConfigFormBase {
       '#attributes' => [
         'id' => 'project_browser',
       ],
+      '#tabledrag' => [],
     ];
     $options = [
       'enabled' => $this->t('Enabled'),
@@ -117,6 +118,7 @@ final class SettingsForm extends ConfigFormBase {
     if (count($source_plugins) > 1) {
       $form['#attached']['library'][] = 'project_browser/tabledrag';
       foreach ($options as $status => $title) {
+        assert(is_array($table['#tabledrag']));
         $table['#tabledrag'][] = [
           'action' => 'match',
           'relationship' => 'sibling',
@@ -135,11 +137,11 @@ final class SettingsForm extends ConfigFormBase {
             'class' => ['status-title', 'status-title-' . $status],
             'no_striping' => TRUE,
           ],
-        ];
-        $table['status-' . $status]['title'] = [
-          '#plain_text' => $title,
-          '#wrapper_attributes' => [
-            'colspan' => 4,
+          'title' => [
+            '#plain_text' => $title,
+            '#wrapper_attributes' => [
+              'colspan' => 4,
+            ],
           ],
         ];
 
diff --git a/src/Plugin/ProjectBrowserSource/DrupalCore.php b/src/Plugin/ProjectBrowserSource/DrupalCore.php
index 28df408e8..e08871abc 100644
--- a/src/Plugin/ProjectBrowserSource/DrupalCore.php
+++ b/src/Plugin/ProjectBrowserSource/DrupalCore.php
@@ -128,7 +128,7 @@ final class DrupalCore extends ProjectBrowserSourceBase {
 
     // Filter by coverage.
     if (!empty($query['security_advisory_coverage'])) {
-      $projects = array_filter($projects, fn(Project $project) => $project->isCovered);
+      $projects = array_filter($projects, fn(Project $project) => $project->isCovered ?? FALSE);
     }
 
     // Filter by categories.
diff --git a/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php b/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
index 48b3be9de..3b8e26f0d 100644
--- a/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
+++ b/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
@@ -159,7 +159,7 @@ final class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
           $response_data = Json::decode($response->getBody()->getContents());
 
           $result['data'] = array_merge($result['data'], $response_data['data']);
-          if (!empty($response_data['included'])) {
+          if (!empty($response_data['included']) && !empty($result['included'])) {
             $result['included'] = array_merge($result['included'], $response_data['included']);
           }
           $iterations++;
@@ -313,7 +313,7 @@ final class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
         $uid_info = $project['relationships']['uid']['data'];
 
         $maintenance_status = $project['relationships']['field_maintenance_status']['data'] ?? [];
-        if (!empty($maintenance_status)) {
+        if (is_array($maintenance_status)) {
           $maintenance_status = [
             'id' => $maintenance_status['id'],
             'name' => $related[$maintenance_status['type']][$maintenance_status['id']]['name'],
@@ -328,8 +328,8 @@ final class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
           ];
         }
 
-        $module_categories = $project['relationships']['field_module_categories']['data'] ?? [];
-        if (!empty($module_categories)) {
+        $module_categories = $project['relationships']['field_module_categories']['data'] ?? NULL;
+        if (is_array($module_categories)) {
           $categories = [];
           foreach ($module_categories as $module_category) {
             $categories[] = [
@@ -340,8 +340,8 @@ final class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
           $module_categories = $categories;
         }
 
-        $project_images = $project['relationships']['field_project_images']['data'] ?? [];
-        if (!empty($project_images)) {
+        $project_images = $project['relationships']['field_project_images']['data'] ?? NULL;
+        if (is_array($project_images)) {
           $images = [];
           foreach ($project_images as $image) {
             $uri = self::DRUPAL_ORG_ENDPOINT . $related[$image['type']][$image['id']]['uri']['url'];
@@ -400,8 +400,8 @@ final class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
             'name' => $related[$uid_info['type']][$uid_info['id']]['name'],
           ],
           packageName: $project['attributes']['field_composer_namespace'] ?? 'drupal/' . $machine_name,
-          categories: $module_categories,
-          images: $project_images,
+          categories: $module_categories ?? [],
+          images: $project_images ?? [],
           url: Url::fromUri('https://www.drupal.org/project/' . $machine_name),
         );
         $returned_list[] = $project_object;
@@ -565,7 +565,7 @@ final class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
     if ($extra = $version_object->getVersionExtra()) {
       $version = str_replace("-$extra", '', $version);
     }
-    $minor_version = $version_object->getMinorVersion() ?? 0;
+    $minor_version = $version_object->getMinorVersion() ?? '0';
     $patch_version = explode('.', $version)[2] ?? '0';
 
     return (int) (
diff --git a/src/Plugin/ProjectBrowserSource/Recipes.php b/src/Plugin/ProjectBrowserSource/Recipes.php
index 04372d5d1..1090c70ff 100644
--- a/src/Plugin/ProjectBrowserSource/Recipes.php
+++ b/src/Plugin/ProjectBrowserSource/Recipes.php
@@ -43,6 +43,7 @@ final class Recipes extends ProjectBrowserSourceBase {
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    assert(is_string($container->getParameter('app.root')));
     return new static(
       $container->get(FileSystemInterface::class),
       $container->get('cache.project_browser'),
@@ -89,6 +90,7 @@ final class Recipes extends ProjectBrowserSourceBase {
         }
         else {
           $package = file_get_contents($path . '/composer.json');
+          assert(is_string($package));
           $package = Json::decode($package);
           $package_name = $package['name'];
 
@@ -130,7 +132,7 @@ final class Recipes extends ProjectBrowserSourceBase {
 
     // Filter by coverage.
     if (!empty($query['security_advisory_coverage'])) {
-      $projects = array_filter($projects, fn(Project $project) => $project->isCovered);
+      $projects = array_filter($projects, fn(Project $project) => $project->isCovered ?? FALSE);
     }
 
     // Filter by categories.
@@ -182,6 +184,7 @@ final class Recipes extends ProjectBrowserSourceBase {
     if ($contrib_recipe_names) {
       $path = InstalledVersions::getInstallPath($contrib_recipe_names[0]);
       $path = $this->fileSystem->realpath($path);
+      assert(is_string($path));
 
       $search_in[] = dirname($path);
     }
diff --git a/src/Plugin/ProjectBrowserSourceBase.php b/src/Plugin/ProjectBrowserSourceBase.php
index 4a29a1850..6a481821e 100644
--- a/src/Plugin/ProjectBrowserSourceBase.php
+++ b/src/Plugin/ProjectBrowserSourceBase.php
@@ -90,4 +90,13 @@ abstract class ProjectBrowserSourceBase extends PluginBase implements ProjectBro
     );
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPluginDefinition(): array {
+    $definition = parent::getPluginDefinition();
+    assert(is_array($definition));
+    return $definition;
+  }
+
 }
diff --git a/src/Plugin/ProjectBrowserSourceInterface.php b/src/Plugin/ProjectBrowserSourceInterface.php
index 9bb2d2703..482e153be 100644
--- a/src/Plugin/ProjectBrowserSourceInterface.php
+++ b/src/Plugin/ProjectBrowserSourceInterface.php
@@ -62,4 +62,9 @@ interface ProjectBrowserSourceInterface extends PluginInspectionInterface {
    */
   public function getSortOptions(): array;
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPluginDefinition(): array;
+
 }
diff --git a/src/Plugin/ProjectBrowserSourceManager.php b/src/Plugin/ProjectBrowserSourceManager.php
index 7e913bb29..e7b10717f 100644
--- a/src/Plugin/ProjectBrowserSourceManager.php
+++ b/src/Plugin/ProjectBrowserSourceManager.php
@@ -39,4 +39,16 @@ class ProjectBrowserSourceManager extends DefaultPluginManager {
     $this->setCacheBackend($cache_backend, 'project_browser_source_info_plugins');
   }
 
+  /**
+   * {@inheritdoc}
+   *
+   * @return \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface
+   *   The source plugin.
+   */
+  public function createInstance($plugin_id, array $configuration = []) {
+    $instance = parent::createInstance($plugin_id, $configuration);
+    assert($instance instanceof ProjectBrowserSourceInterface);
+    return $instance;
+  }
+
 }
diff --git a/src/ProjectBrowserServiceProvider.php b/src/ProjectBrowserServiceProvider.php
index cf8dc0b50..595a6a4d9 100644
--- a/src/ProjectBrowserServiceProvider.php
+++ b/src/ProjectBrowserServiceProvider.php
@@ -30,6 +30,7 @@ class ProjectBrowserServiceProvider extends ServiceProviderBase {
    * {@inheritdoc}
    */
   public function alter(ContainerBuilder $container): void {
+    assert(is_array($container->getParameter('container.modules')));
     if (array_key_exists('package_manager', $container->getParameter('container.modules'))) {
       parent::register($container);
 
diff --git a/src/RecipeActivator.php b/src/RecipeActivator.php
index f138b1d5d..ebbaf6ab1 100644
--- a/src/RecipeActivator.php
+++ b/src/RecipeActivator.php
@@ -170,7 +170,7 @@ final class RecipeActivator implements ActivatorInterface, EventSubscriberInterf
       }
     }
 
-    return $path ? $this->fileSystem->realpath($path) : NULL;
+    return $path ? ($this->fileSystem->realpath($path) ?: NULL) : NULL;
   }
 
 }
diff --git a/tests/modules/project_browser_test/project_browser_test.install b/tests/modules/project_browser_test/project_browser_test.install
index a44174e57..a8600740d 100644
--- a/tests/modules/project_browser_test/project_browser_test.install
+++ b/tests/modules/project_browser_test/project_browser_test.install
@@ -130,7 +130,9 @@ function project_browser_test_install(): void {
   $module_path = \Drupal::service('module_handler')->getModule('project_browser')->getPath();
 
   $category_values = [];
-  $projects = Json::decode(file_get_contents($module_path . '/tests/fixtures/projects_fixture.json'));
+  $contents = file_get_contents($module_path . '/tests/fixtures/projects_fixture.json');
+  assert(is_string($contents));
+  $projects = Json::decode($contents);
   // Insert fixture data to the database.
   $query = $connection->insert('project_browser_projects')->fields([
     'nid',
diff --git a/tests/modules/project_browser_test/src/Datetime/TestTime.php b/tests/modules/project_browser_test/src/Datetime/TestTime.php
index 473b00f8f..b4b4100c6 100644
--- a/tests/modules/project_browser_test/src/Datetime/TestTime.php
+++ b/tests/modules/project_browser_test/src/Datetime/TestTime.php
@@ -46,7 +46,9 @@ class TestTime implements TimeInterface {
   public function getRequestTime(): int {
     // @phpstan-ignore-next-line
     if ($faked_date = \Drupal::state()->get('project_browser_test.fake_date_time')) {
-      return \DateTime::createFromFormat('U', $faked_date)->getTimestamp();
+      if ($date_time = \DateTime::createFromFormat('U', $faked_date)) {
+        return $date_time->getTimestamp();
+      }
     }
     return $this->decorated->getRequestTime();
   }
diff --git a/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php b/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php
index 035e9575f..c76a7a580 100644
--- a/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php
+++ b/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php
@@ -128,9 +128,10 @@ class DrupalOrgClientMiddleware {
           $path_to_fixture = self::DRUPALORG_JSONAPI_ENDPOINT_TO_FIXTURE_MAP;
           if (isset($path_to_fixture[$relevant_path])) {
             $module_path = $this->moduleHandler->getModule('project_browser')->getPath();
-            $data = file_get_contents($module_path . '/tests/fixtures/drupalorg_jsonapi/' . $path_to_fixture[$relevant_path]);
-            $json_response = new Response(200, [], $data);
-            return new FulfilledPromise($json_response);
+            if ($data = file_get_contents($module_path . '/tests/fixtures/drupalorg_jsonapi/' . $path_to_fixture[$relevant_path])) {
+              $json_response = new Response(200, [], $data);
+              return new FulfilledPromise($json_response);
+            }
           }
 
           throw new \Exception('Attempted call to the Drupal.org jsonapi endpoint that is not mocked in middleware: ' . $relevant_path);
@@ -151,9 +152,10 @@ class DrupalOrgClientMiddleware {
 
           if (isset($path_to_fixture[$relevant_path])) {
             $module_path = $this->moduleHandler->getModule('project_browser')->getPath();
-            $data = file_get_contents($module_path . '/tests/fixtures/drupalorg_jsonapi/' . $path_to_fixture[$relevant_path]);
-            $json_response = new Response(200, [], $data);
-            return new FulfilledPromise($json_response);
+            if ($data = file_get_contents($module_path . '/tests/fixtures/drupalorg_jsonapi/' . $path_to_fixture[$relevant_path])) {
+              $json_response = new Response(200, [], $data);
+              return new FulfilledPromise($json_response);
+            }
           }
 
           throw new \Exception('Attempted call to the Drupal.org endpoint that is not mocked in middleware: ' . $relevant_path);
diff --git a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
index d8b0807d9..3b8b8273d 100644
--- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
+++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
@@ -288,7 +288,9 @@ final class ProjectBrowserTestMock extends ProjectBrowserSourceBase {
    */
   protected function getCategoryData(): array {
     $module_path = $this->moduleHandler->getModule('project_browser')->getPath();
-    $category_list = Json::decode(file_get_contents($module_path . '/tests/fixtures/category_list.json')) ?? [];
+    $contents = file_get_contents($module_path . '/tests/fixtures/category_list.json');
+    assert(is_string($contents));
+    $category_list = Json::decode($contents) ?? [];
     $categories = [];
     foreach ($category_list as $category) {
       $categories[$category['tid']] = [
@@ -363,7 +365,7 @@ final class ProjectBrowserTestMock extends ProjectBrowserSourceBase {
     $categories = $this->getCategoryData();
 
     $returned_list = [];
-    if ($api_response) {
+    if (is_array($api_response)) {
       foreach ($api_response['list'] as $project_data) {
         $avatar_url = 'https://git.drupalcode.org/project/' . $project_data['field_project_machine_name'] . '/-/avatar';
         $logo = [
@@ -381,7 +383,7 @@ final class ProjectBrowserTestMock extends ProjectBrowserSourceBase {
           isCompatible: TRUE,
           isMaintained: in_array($project_data['maintenance_status'], self::MAINTAINED_VALUES),
           isCovered: in_array($project_data['field_security_advisory_coverage'], self::COVERED_VALUES),
-          projectUsageTotal: array_reduce($project_data['project_data']['project_usage'] ?? [], fn($total, $project_usage) => $total + $project_usage) ?: 0,
+          projectUsageTotal: (int) array_reduce($project_data['project_data']['project_usage'] ?? [], fn($total, $project_usage) => $total + $project_usage) ?: 0,
           machineName: $project_data['field_project_machine_name'],
           body: $this->relativeToAbsoluteUrls($project_data['project_data']['body'], 'https://www.drupal.org'),
           title: $project_data['title'],
diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index dd18731db..9f9a05ca1 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -54,6 +54,7 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
     $handler = $this->container->get(EnabledSourceHandler::class);
 
     $projects = $handler->getProjects('project_browser_test_mock');
+    assert(!empty($projects));
     $list = reset($projects)->list;
     $this->assertNotEmpty($list);
     $project = reset($list);
@@ -74,6 +75,7 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
     // and commands set.
     $projects = $this->container->get(EnabledSourceHandler::class)
       ->getProjects('project_browser_test_mock');
+    assert(!empty($projects));
     $list = reset($projects)->list;
     $this->assertNotEmpty($list);
     $project = reset($list);
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 4b584a872..1bb7a0d50 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -164,7 +164,9 @@ class InstallerControllerTest extends BrowserTestBase {
     ]);
     $query->execute();
     $this->initPackageManager();
-    $this->installer = $this->container->get(Installer::class);
+    /** @var \Drupal\project_browser\ComposerInstaller\Installer $installer */
+    $installer = $this->container->get(Installer::class);
+    $this->installer = $installer;
     $this->drupalLogin($this->drupalCreateUser(['administer modules']));
     $this->config('project_browser.admin_settings')
       ->set('enabled_sources', ['project_browser_test_mock', 'drupal_core'])
@@ -193,7 +195,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::begin
    */
   public function testInstallSecurityRevokedModule(): void {
-    $this->assertSame([], $this->container->get(InstallState::class)->toArray());
+    $this->assertSame([], $this->getInstallState()->toArray());
     $contents = $this->drupalGet('admin/modules/project_browser/install-begin');
     $this->stageId = Json::decode($contents)['stage_id'];
     $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/security_revoked_module', [
@@ -209,7 +211,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::require
    */
   public function testInstallAlreadyPresentPackage(): void {
-    $this->assertSame([], $this->container->get(InstallState::class)->toArray());
+    $this->assertSame([], $this->getInstallState()->toArray());
     // Though core is not available as a choice in project browser, it works
     // well for the purposes of this test as it's definitely already added
     // via composer.
@@ -228,7 +230,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::begin
    */
   private function doStart(): void {
-    $this->assertSame([], $this->container->get(InstallState::class)->toArray());
+    $this->assertSame([], $this->getInstallState()->toArray());
     $contents = $this->drupalGet('admin/modules/project_browser/install-begin');
     $this->stageId = Json::decode($contents)['stage_id'];
     $this->assertSession()->statusCodeEquals(200);
@@ -450,10 +452,14 @@ class InstallerControllerTest extends BrowserTestBase {
       $this->assertSame($expected_message, $response['message']);
 
       if ($response['unlock_url']) {
-        $this->assertStringEndsWith('/admin/modules/project_browser/install/unlock', parse_url($response['unlock_url'], PHP_URL_PATH));
-        $query = parse_url($response['unlock_url'], PHP_URL_QUERY);
-        parse_str($query, $query);
+        $path_string = parse_url($response['unlock_url'], PHP_URL_PATH);
+        $this->assertIsString($path_string);
+        $this->assertStringEndsWith('/admin/modules/project_browser/install/unlock', $path_string);
+        $query_string = parse_url($response['unlock_url'], PHP_URL_QUERY);
+        $this->assertIsString($query_string);
+        parse_str($query_string, $query);
         $this->assertNotEmpty($query['token']);
+        $this->assertIsString($query['destination']);
         $this->assertStringEndsWith('/admin/modules/browse/project_browser_test_mock', $query['destination']);
       }
     };
@@ -468,7 +474,7 @@ class InstallerControllerTest extends BrowserTestBase {
         'status' => 'requiring',
       ],
     ];
-    $this->assertSame($expected, $this->container->get(InstallState::class)->toArray());
+    $this->assertSame($expected, $this->getInstallState()->toArray());
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     TestTime::setFakeTimeByOffset("+800 seconds");
@@ -517,8 +523,8 @@ class InstallerControllerTest extends BrowserTestBase {
     $json = Json::decode($content);
     $this->assertSame('The process for adding projects is locked, but that lock has expired. Use [+ unlock link] to unlock the process and try to add the project again.', $json['message']);
     $unlock_url = parse_url($json['unlock_url']);
-    parse_str($unlock_url['query'], $unlock_url['query']);
-    $unlock_content = $this->drupalGet($unlock_url['path'], ['query' => $unlock_url['query']]);
+    parse_str($unlock_url['query'] ?? '', $unlock_url['query']);
+    $unlock_content = $this->drupalGet($unlock_url['path'] ?? '', ['query' => $unlock_url['query']]);
     $this->assertSession()->statusCodeEquals(200);
     $this->assertTrue($this->installer->isAvailable());
     $this->assertStringContainsString('Operation complete, you can add a new project again.', $unlock_content);
@@ -533,7 +539,7 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   public function testCanBreakStageWithMissingProjectBrowserLock(): void {
     $this->doStart();
-    $this->container->get(InstallState::class)->deleteAll();
+    $this->getInstallState()->deleteAll();
     $content = $this->drupalGet('admin/modules/project_browser/install-begin', [
       'query' => ['source' => 'project_browser_test_mock'],
     ]);
@@ -543,8 +549,8 @@ class InstallerControllerTest extends BrowserTestBase {
     $json = Json::decode($content);
     $this->assertSame('The process for adding projects is locked, but that lock has expired. Use [+ unlock link] to unlock the process and try to add the project again.', $json['message']);
     $unlock_url = parse_url($json['unlock_url']);
-    parse_str($unlock_url['query'], $unlock_url['query']);
-    $unlock_content = $this->drupalGet($unlock_url['path'], ['query' => $unlock_url['query']]);
+    parse_str($unlock_url['query'] ?? '', $unlock_url['query']);
+    $unlock_content = $this->drupalGet($unlock_url['path'] ?? '', ['query' => $unlock_url['query']]);
     $this->assertSession()->statusCodeEquals(200);
     $this->assertTrue($this->installer->isAvailable());
     $this->assertStringContainsString('Operation complete, you can add a new project again.', $unlock_content);
@@ -592,7 +598,7 @@ class InstallerControllerTest extends BrowserTestBase {
       'source' => $source,
       'status' => $status,
     ];
-    $this->assertSame($expect_install, $this->container->get(InstallState::class)->toArray());
+    $this->assertSame($expect_install, $this->getInstallState()->toArray());
     $this->drupalGet("/admin/modules/project_browser/install_in_progress/$project_id");
     $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $status), $this->getSession()->getPage()->getContent());
     $this->drupalGet('/admin/modules/project_browser/install_in_progress/project_browser_test_mock/metatag');
@@ -626,4 +632,16 @@ class InstallerControllerTest extends BrowserTestBase {
     return $this->makeApiRequest('POST', $post_url, $request_options);
   }
 
+  /**
+   * Gets the install state.
+   *
+   * @return \Drupal\project_browser\InstallState
+   *   The install state.
+   */
+  private function getInstallState(): InstallState {
+    $install_state = $this->container->get(InstallState::class);
+    assert($install_state instanceof InstallState);
+    return $install_state;
+  }
+
 }
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 1bfe7eac7..71b145438 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -55,7 +55,9 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     $this->initPackageManager();
 
-    $this->installState = $this->container->get(InstallState::class);
+    /** @var \Drupal\project_browser\InstallState $install_state */
+    $install_state = $this->container->get(InstallState::class);
+    $this->installState = $install_state;
 
     $this->config('project_browser.admin_settings')
       ->set('enabled_sources', ['project_browser_test_mock'])
@@ -451,10 +453,14 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->assertSame('Install Cream cheese on a bagel', $download_button->getText());
     $download_button->click();
     $unlock_url = $assert_session->waitForElementVisible('css', "#unlock-link")->getAttribute('href');
-    $this->assertStringEndsWith('/admin/modules/project_browser/install/unlock', parse_url($unlock_url, PHP_URL_PATH));
-    $query = parse_url($unlock_url, PHP_URL_QUERY);
-    parse_str($query, $query);
+    $path_string = parse_url($unlock_url, PHP_URL_PATH);
+    $this->assertIsString($path_string);
+    $this->assertStringEndsWith('/admin/modules/project_browser/install/unlock', $path_string);
+    $query_string = parse_url($unlock_url, PHP_URL_QUERY);
+    $this->assertIsString($query_string);
+    parse_str($query_string, $query);
     $this->assertNotEmpty($query['token']);
+    $this->assertIsString($query['destination']);
     $this->assertStringEndsWith('/admin/modules/browse/project_browser_test_mock', $query['destination']);
   }
 
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index eb869dd1f..d65af3c06 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -214,7 +214,9 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     // The first textarea should have the command to require the module.
     $this->assertSame('composer require drupal/helvetica', $command_boxes[0]->getValue());
     // And the second textarea should have the command to install it.
-    $this->assertStringEndsWith('drush install helvetica', $command_boxes[1]->getValue());
+    $value = $command_boxes[1]->getValue();
+    $this->assertIsString($value);
+    $this->assertStringEndsWith('drush install helvetica', $value);
 
     // Tests alt text for copy command image.
     $download_commands = $page->findAll('css', '.command-box img');
diff --git a/tests/src/Kernel/CoreExperimentalLabelTest.php b/tests/src/Kernel/CoreExperimentalLabelTest.php
index 36a2249a1..05725211e 100644
--- a/tests/src/Kernel/CoreExperimentalLabelTest.php
+++ b/tests/src/Kernel/CoreExperimentalLabelTest.php
@@ -34,6 +34,7 @@ class CoreExperimentalLabelTest extends KernelTestBase {
    * @covers ::getProjectData
    */
   public function testCoreExperimentalLabel(): void {
+    /** @var \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface $plugin_instance */
     $plugin_instance = $this->container->get(ProjectBrowserSourceManager::class)
       ->createInstance('drupal_core');
     $modules_to_test = ['Experimental Test', 'System'];
diff --git a/tests/src/Kernel/CoreNotUpdatedValidatorTest.php b/tests/src/Kernel/CoreNotUpdatedValidatorTest.php
index ef1599beb..ee83bd591 100644
--- a/tests/src/Kernel/CoreNotUpdatedValidatorTest.php
+++ b/tests/src/Kernel/CoreNotUpdatedValidatorTest.php
@@ -75,6 +75,7 @@ class CoreNotUpdatedValidatorTest extends PackageManagerKernelTestBase {
     if ($core_updated) {
       $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
     }
+    /** @var \Drupal\project_browser\ComposerInstaller\Installer $installer */
     $installer = $this->container->get(Installer::class);
     $installer->create();
     $installer->require(['org/package-name']);
diff --git a/tests/src/Kernel/InstallerTest.php b/tests/src/Kernel/InstallerTest.php
index e595444fe..04cc7ab64 100644
--- a/tests/src/Kernel/InstallerTest.php
+++ b/tests/src/Kernel/InstallerTest.php
@@ -79,14 +79,15 @@ class InstallerTest extends PackageManagerKernelTestBase {
   /**
    * Tests exception handling during calls to Composer Stager commit.
    *
-   * @param string $thrown_class
+   * @param class-string<\Throwable> $thrown_class
    *   The throwable class that should be thrown by Composer Stager.
-   * @param string|null $expected_class
+   * @param class-string<\Throwable> $expected_class
    *   The expected exception class.
    *
    * @dataProvider providerCommitException
    */
-  public function testCommitException(string $thrown_class, ?string $expected_class = NULL): void {
+  public function testCommitException(string $thrown_class, string $expected_class): void {
+    /** @var \Drupal\project_browser\ComposerInstaller\Installer $installer */
     $installer = $this->container->get(Installer::class);
     $installer->create();
     $installer->require(['org/package-name']);
@@ -114,6 +115,7 @@ class InstallerTest extends PackageManagerKernelTestBase {
    * @covers ::dispatch
    */
   public function testInstallException(): void {
+    /** @var \Drupal\project_browser\ComposerInstaller\Installer $installer */
     $installer = $this->container->get(Installer::class);
     $installer->create();
     $installer->require(['org/package-name']);
diff --git a/tests/src/Kernel/PackageNotInstalledValidatorTest.php b/tests/src/Kernel/PackageNotInstalledValidatorTest.php
index 8a8bd2cc0..eff4ae007 100644
--- a/tests/src/Kernel/PackageNotInstalledValidatorTest.php
+++ b/tests/src/Kernel/PackageNotInstalledValidatorTest.php
@@ -88,6 +88,7 @@ class PackageNotInstalledValidatorTest extends PackageManagerKernelTestBase {
       // entry for it, so we can 'composer require' it later.
       ->removePackage('drupal/new_module')
       ->commitChanges();
+    /** @var \Drupal\project_browser\ComposerInstaller\Installer $installer */
     $installer = $this->container->get(Installer::class);
     try {
       $installer->create();
diff --git a/tests/src/Kernel/RecipeActivatorTest.php b/tests/src/Kernel/RecipeActivatorTest.php
index c832f0d1c..c25952a2e 100644
--- a/tests/src/Kernel/RecipeActivatorTest.php
+++ b/tests/src/Kernel/RecipeActivatorTest.php
@@ -56,6 +56,7 @@ class RecipeActivatorTest extends KernelTestBase {
       packageName: 'My Project',
       type: ProjectType::Recipe,
     );
+    /** @var \Drupal\project_browser\ActivatorInterface $activator */
     $activator = $this->container->get(ActivatorInterface::class);
     // As this project is not installed the RecipeActivator::getPath() will
     // return NULL in RecipeActivator::getStatus() and it will return the
diff --git a/tests/src/Kernel/RecipesSourceTest.php b/tests/src/Kernel/RecipesSourceTest.php
index f6fa34f07..32f993ffd 100644
--- a/tests/src/Kernel/RecipesSourceTest.php
+++ b/tests/src/Kernel/RecipesSourceTest.php
@@ -68,8 +68,7 @@ class RecipesSourceTest extends KernelTestBase {
     $this->setSetting('extension_discovery_scan_tests', TRUE);
 
     /** @var \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface $source */
-    $source = $this->container->get(ProjectBrowserSourceManager::class)
-      ->createInstance('recipes');
+    $source = $this->container->get(ProjectBrowserSourceManager::class)->createInstance('recipes');
 
     // Generate a fake recipe in the temporary directory.
     $generated_recipe_name = uniqid();
@@ -149,10 +148,9 @@ class RecipesSourceTest extends KernelTestBase {
       ])
       ->save();
 
-    /** @var \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $projects */
-    $projects = $this->container->get(ProjectBrowserSourceManager::class)
-      ->createInstance('recipes')
-      ->getProjects();
+    /** @var \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface $source */
+    $source = $this->container->get(ProjectBrowserSourceManager::class)->createInstance('recipes');
+    $projects = $source->getProjects();
     $found_recipe_names = array_column($projects->list, 'machineName');
 
     // The `example` recipe (from core) should always be hidden, even if it's in
-- 
GitLab