You are here

public function DeprecationAnalyzer::analyze in Upgrade Status 8.3

Same name and namespace in other branches
  1. 8.2 src/DeprecationAnalyzer.php \Drupal\upgrade_status\DeprecationAnalyzer::analyze()

Analyze the codebase of an extension including all its sub-components.

Parameters

\Drupal\Core\Extension\Extension $extension: The extension to analyze.

Return value

null Errors are logged to the logger, data is stored to keyvalue storage.

File

src/DeprecationAnalyzer.php, line 263

Class

DeprecationAnalyzer

Namespace

Drupal\upgrade_status

Code

public function analyze(Extension $extension) {
  try {
    $this
      ->initEnvironment();
  } catch (\Exception $e) {

    // Should not get here as integrations are expected to invoke
    // initEnvironment() first by itself to ensure the environment
    // is going to work when needed (and inform users about any
    // issues). That said, if they did not do that and there was
    // no issue with the environment, then they are lucky.
    return;
  }
  $project_dir = DRUPAL_ROOT . '/' . $extension
    ->getPath();
  $this->logger
    ->notice('Processing %path.', [
    '%path' => $project_dir,
  ]);
  $output = [];
  $error_filename = $this->temporaryDirectory . '/phpstan_error_output';
  $command = $this->binPath . '/phpstan analyse --memory-limit=-1 --error-format=json -c ' . $this->phpstanNeonPath . ' ' . $project_dir . ' 2> ' . $error_filename;
  exec($command, $output);
  $json = json_decode(implode('', $output), TRUE);
  if (!isset($json['files']) || !is_array($json['files'])) {
    $stdout = trim(implode('', $output)) ?: 'Empty.';
    $stderr = trim(file_get_contents($error_filename)) ?: 'Empty.';
    $formatted_error = "<h6>PHPStan command failed:</h6> <p>" . $command . "</p> <h6>Command output:</h6> <p>" . $stdout . "</p> <h6>Command error:</h6> <p>" . $stderr . '</p>';
    $this->logger
      ->error('%phpstan_fail', [
      '%phpstan_fail' => strip_tags($formatted_error),
    ]);
    $json = [
      'files' => [
        // Add a failure message with the nonexistent 'PHPStan failed'
        // filename, so the error conforms to the expected format.
        'PHPStan failed' => [
          'messages' => [
            [
              'message' => $formatted_error,
              'line' => 0,
            ],
          ],
        ],
      ],
      'totals' => [
        'errors' => 1,
        'file_errors' => 1,
      ],
    ];
  }
  $result = [
    'date' => $this->time
      ->getRequestTime(),
    'data' => $json,
  ];
  $twig_deprecations = $this
    ->analyzeTwigTemplates($extension
    ->getPath());
  foreach ($twig_deprecations as $twig_deprecation) {
    preg_match('/\\s([a-zA-Z0-9\\_\\-\\/]+.html\\.twig)\\s/', $twig_deprecation, $file_matches);
    preg_match('/\\s(\\d+).?$/', $twig_deprecation, $line_matches);
    $twig_deprecation = preg_replace('! in (.+)\\.twig at line \\d+\\.!', '.', $twig_deprecation);
    $twig_deprecation .= ' See https://drupal.org/node/3071078.';
    $result['data']['files'][$file_matches[1]]['messages'][] = [
      'message' => $twig_deprecation,
      'line' => $line_matches[1] ?: 0,
    ];
    $result['data']['totals']['errors']++;
    $result['data']['totals']['file_errors']++;
  }
  $deprecation_messages = $this->libraryDeprecationAnalyzer
    ->analyze($extension);
  foreach ($deprecation_messages as $deprecation_message) {
    $result['data']['files'][$deprecation_message
      ->getFile()]['messages'][] = [
      'message' => $deprecation_message
        ->getMessage(),
      'line' => $deprecation_message
        ->getLine(),
    ];
    $result['data']['totals']['errors']++;
    $result['data']['totals']['file_errors']++;
  }
  $theme_function_deprecations = $this->themeFunctionDeprecationAnalyzer
    ->analyze($extension);
  foreach ($theme_function_deprecations as $deprecation_message) {
    $result['data']['files'][$deprecation_message
      ->getFile()]['messages'][] = [
      'message' => $deprecation_message
        ->getMessage(),
      'line' => $deprecation_message
        ->getLine(),
    ];
    $result['data']['totals']['errors']++;
    $result['data']['totals']['file_errors']++;
  }

  // Assume this project is ready for the next major core version unless proven otherwise.
  $result['data']['totals']['upgrade_status_split']['declared_ready'] = TRUE;
  $info_files = $this
    ->getSubExtensionInfoFiles($project_dir);
  foreach ($info_files as $info_file) {
    try {

      // Manually add on info file incompatibility to results. Reading
      // .info.yml files directly, not from extension discovery because that
      // is cached.
      $info = Yaml::decode(file_get_contents($info_file)) ?: [];
      if (!empty($info['package']) && $info['package'] == 'Testing' && !strpos($info_file, '/upgrade_status_test')) {

        // If this info file was for a testing project other than our own
        // testing projects, ignore it.
        continue;
      }
      $error_path = str_replace(DRUPAL_ROOT . '/', '', $info_file);

      // Check for missing base theme key.
      if ($info['type'] === 'theme') {
        if (!isset($info['base theme'])) {
          $result['data']['files'][$error_path]['messages'][] = [
            'message' => "The now required 'base theme' key is missing. See https://www.drupal.org/node/3066038.",
            'line' => 0,
          ];
          $result['data']['totals']['errors']++;
          $result['data']['totals']['file_errors']++;
        }
      }
      if (!isset($info['core_version_requirement'])) {
        $result['data']['files'][$error_path]['messages'][] = [
          'message' => "Add core_version_requirement: ^8 || ^9 to designate that the module is compatible with Drupal 9. See https://drupal.org/node/3070687.",
          'line' => 0,
        ];
        $result['data']['totals']['errors']++;
        $result['data']['totals']['file_errors']++;
        $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
      }
      elseif (!ProjectCollector::isCompatibleWithNextMajorDrupal($info['core_version_requirement'])) {
        $result['data']['files'][$error_path]['messages'][] = [
          'message' => "Value of core_version_requirement: {$info['core_version_requirement']} is not compatible with the next major version of Drupal core. See https://drupal.org/node/3070687.",
          'line' => 0,
        ];
        $result['data']['totals']['errors']++;
        $result['data']['totals']['file_errors']++;
        $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
      }

      // @todo
      //   Change values to ExtensionLifecycle class constants once at least
      //   Drupal 9.3 is required.
      if (!empty($info['lifecycle'])) {
        $link = !empty($info['lifecycle_link']) ? $info['lifecycle_link'] : 'https://www.drupal.org/node/3215042';
        if ($info['lifecycle'] == 'deprecated') {
          $result['data']['files'][$error_path]['messages'][] = [
            'message' => "This extension is deprecated. Don't use it. See {$link}.",
            'line' => 0,
          ];
          $result['data']['totals']['errors']++;
          $result['data']['totals']['file_errors']++;
          $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
        }
        elseif ($info['lifecycle'] == 'obsolete') {
          $result['data']['files'][$error_path]['messages'][] = [
            'message' => "This extension is obsolete. Obsolete extensions are usually uninstalled automatically when not needed anymore. You only need to do something about this if the uninstallation was unsuccesful. See {$link}.",
            'line' => 0,
          ];
          $result['data']['totals']['errors']++;
          $result['data']['totals']['file_errors']++;
          $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
        }
      }
    } catch (InvalidDataTypeException $e) {
      $result['data']['files'][$error_path]['messages'][] = [
        'message' => 'Parse error. ' . $e
          ->getMessage(),
        'line' => 0,
      ];
      $result['data']['totals']['errors']++;
      $result['data']['totals']['file_errors']++;
      $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
    }

    // No need to check info files for PHP 8 compatibility information because
    // they can only define minimal PHP versions not maximum or excluded PHP
    // versions.
  }

  // Manually add on composer.json file incompatibility to results.
  if (file_exists($project_dir . '/composer.json')) {
    $composer_json = json_decode(file_get_contents($project_dir . '/composer.json'));
    if (empty($composer_json) || !is_object($composer_json)) {
      $result['data']['files'][$extension
        ->getPath() . '/composer.json']['messages'][] = [
        'message' => "Parse error in composer.json. Having a composer.json is not a requirement in general, but if there is one, it should be valid. See https://drupal.org/node/2514612.",
        'line' => 0,
      ];
      $result['data']['totals']['errors']++;
      $result['data']['totals']['file_errors']++;
      $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
    }
    elseif (!empty($composer_json->require->{'drupal/core'}) && !projectCollector::isCompatibleWithNextMajorDrupal($composer_json->require->{'drupal/core'})) {
      $result['data']['files'][$extension
        ->getPath() . '/composer.json']['messages'][] = [
        'message' => "The drupal/core requirement is not compatible with the next major version of Drupal. Either remove it or update it to be compatible. See https://drupal.org/node/2514612#s-drupal-9-compatibility.",
        'line' => 0,
      ];
      $result['data']['totals']['errors']++;
      $result['data']['totals']['file_errors']++;
      $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
    }
    elseif (projectCollector::getDrupalCoreMajorVersion() > 8 && !empty($composer_json->require->{'php'} && !projectCollector::isCompatibleWithPHP8($composer_json->require->{'php'}))) {
      $result['data']['files'][$extension
        ->getPath() . '/composer.json']['messages'][] = [
        'message' => "The PHP requirement is not compatible with PHP 8. Once the codebase is actually compatible, either remove this limitation or update it to be compatible.",
        'line' => 0,
      ];
      $result['data']['totals']['errors']++;
      $result['data']['totals']['file_errors']++;
      $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
    }
  }

  // Assume next step is to relax (there were no errors found).
  $result['data']['totals']['upgrade_status_next'] = ProjectCollector::NEXT_RELAX;
  foreach ($result['data']['files'] as $path => &$errors) {
    foreach ($errors['messages'] as &$error) {

      // Overwrite message with processed text. Save category.
      [
        $message,
        $category,
      ] = $this
        ->categorizeMessage($error['message'], $extension);
      $error['message'] = $message;
      $error['upgrade_status_category'] = $category;

      // If the category was 'rector' that means at least one error was
      // identified as covered by rector, so next step should be to run
      // rector on this project.
      if ($category == 'rector') {
        $result['data']['totals']['upgrade_status_next'] = ProjectCollector::NEXT_RECTOR;
      }
      elseif ($result['data']['totals']['upgrade_status_next'] == ProjectCollector::NEXT_RELAX) {
        $result['data']['totals']['upgrade_status_next'] = ProjectCollector::NEXT_MANUAL;
      }

      // Sum up the error based on the category it ended up in. Split the
      // categories into two high level buckets needing attention now or
      // later for compatibility with the next major version. Issues in the
      // 'ignore' category are intentionally not counted in either.
      @$result['data']['totals']['upgrade_status_category'][$category]++;
      if (in_array($category, [
        'safe',
        'old',
        'rector',
      ])) {
        @$result['data']['totals']['upgrade_status_split']['error']++;
      }
      elseif (in_array($category, [
        'later',
        'uncategorized',
      ])) {
        @$result['data']['totals']['upgrade_status_split']['warning']++;
      }
    }
  }

  // For contributed projects, attempt to grab upgrade plan information.
  if (!empty($extension->info['project'])) {
    try {

      /** @var \Psr\Http\Message\ResponseInterface $response */
      $response = $this->httpClient
        ->request('GET', 'https://www.drupal.org/api-d7/node.json?field_project_machine_name=' . $extension
        ->getName());
      if ($response
        ->getStatusCode()) {
        $data = json_decode($response
          ->getBody(), TRUE);
        if (!empty($data['list'][0]['field_next_major_version_info']['value'])) {
          $result['plans'] = str_replace('href="/', 'href="https://drupal.org/', $data['list'][0]['field_next_major_version_info']['value']);

          // @todo implement "replaced by" collection once drupal.org exposes
          // that in an accessible way
          // @todo once/if drupal.org deprecation testing is in place, grab
          // the status from there so we know if it improves by updating
        }
      }
    } catch (\Exception $e) {
      $this->logger
        ->error($e
        ->getMessage());
    }
  }

  // Store the analysis results in our storage bin.
  $this->scanResultStorage
    ->set($extension
    ->getName(), $result);
}