View source
<?php
namespace Drupal\upgrade_status;
use Composer\Semver\Semver;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Serialization\Yaml;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
use Drupal\Core\Extension\Extension;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\Template\TwigEnvironment;
use DrupalFinder\DrupalFinder;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
final class DeprecationAnalyzer {
const CORE_MINOR_OLDEST_SUPPORTED = '8.8';
protected $scanResultStorage;
protected $logger;
protected $phpstanNeonPath;
protected $vendorPath;
protected $binPath;
protected $temporaryDirectory;
protected $httpClient;
protected $fileSystem;
protected $twigEnvironment;
protected $libraryDeprecationAnalyzer;
protected $themeFunctionDeprecationAnalyzer;
protected $time;
protected $finder;
protected $environmentInitialized = FALSE;
public function __construct(KeyValueFactoryInterface $key_value_factory, LoggerInterface $logger, Client $http_client, FileSystemInterface $file_system, TwigEnvironment $twig_environment, LibraryDeprecationAnalyzer $library_deprecation_analyzer, ThemeFunctionDeprecationAnalyzer $theme_function_deprecation_analyzer, TimeInterface $time) {
$this->scanResultStorage = $key_value_factory
->get('upgrade_status_scan_results');
$this->logger = $logger;
$this->httpClient = $http_client;
$this->fileSystem = $file_system;
$this->twigEnvironment = $twig_environment;
$this->libraryDeprecationAnalyzer = $library_deprecation_analyzer;
$this->themeFunctionDeprecationAnalyzer = $theme_function_deprecation_analyzer;
$this->time = $time;
}
public function initEnvironment() {
if (!empty($this->environmentInitialized)) {
return;
}
$this->finder = new DrupalFinder();
$this->finder
->locateRoot(DRUPAL_ROOT);
$this->vendorPath = $this->finder
->getVendorDir();
$this->binPath = $this
->findBinPath();
if (function_exists('file_directory_temp')) {
$system_temporary = file_directory_temp();
}
else {
$system_temporary = $this->fileSystem
->getTempDirectory();
}
$this->temporaryDirectory = $system_temporary . '/upgrade_status';
if (!file_exists($this->temporaryDirectory)) {
$this
->prepareTempDirectory();
}
$this->phpstanNeonPath = $this->temporaryDirectory . '/deprecation_testing.neon';
$this
->createModifiedNeonFile();
$this->environmentInitialized = TRUE;
}
protected function findBinPath() {
if (file_exists($this->vendorPath . '/bin/phpstan')) {
return $this->vendorPath . '/bin';
}
else {
$attempted_paths = [
$this->vendorPath . '/bin/phpstan',
];
}
$composerFileName = trim(getenv('COMPOSER')) ?: 'composer.json';
$rootComposer = $this->finder
->getComposerRoot() . '/' . $composerFileName;
$json = json_decode(file_get_contents($rootComposer), TRUE);
if (is_null($json)) {
throw new \Exception('Unable to decode composer information from ' . $rootComposer);
}
if (is_array($json) && isset($json['config']['bin-dir'])) {
$binPath = $this->finder
->getComposerRoot() . '/' . $json['config']['bin-dir'];
if (file_exists($binPath . '/phpstan')) {
return $binPath;
}
else {
$attempted_paths[] = $binPath . '/phpstan';
}
}
throw new \Exception('Vendor binary path not correct or phpstan is not installed there. Did you install Upgrade Status with composer? Checked: ' . join(',', $attempted_paths));
}
public function analyze(Extension $extension) {
try {
$this
->initEnvironment();
} catch (\Exception $e) {
return;
}
$project_dir = DRUPAL_ROOT . '/' . $extension
->getPath();
$this->logger
->notice('Processing %path.', [
'%path' => $project_dir,
]);
$output = [];
exec($this->binPath . '/phpstan analyse --error-format=json -c ' . $this->phpstanNeonPath . ' ' . $project_dir, $output);
$json = json_decode(implode('', $output), TRUE);
if (!isset($json['files']) || !is_array($json['files'])) {
$this->logger
->error('PHPStan failed: %results', [
'%results' => print_r($output, TRUE),
]);
$json = [
'files' => [
'PHPStan failed' => 'PHP API deprecations cannot be checked. Reason: ' . print_r($output, TRUE),
'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']++;
}
$result['data']['totals']['upgrade_status_split']['declared_ready'] = TRUE;
$info_files = $this
->getSubExtensionInfoFiles($project_dir);
foreach ($info_files as $info_file) {
try {
$info = Yaml::decode(file_get_contents($info_file)) ?: [];
if (!empty($info['package']) && $info['package'] == 'Testing' && !strpos($info_file, '/upgrade_status_test')) {
continue;
}
$error_path = str_replace(DRUPAL_ROOT . '/', '', $info_file);
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 (!Semver::satisfies('9.0.0', $info['core_version_requirement'])) {
$result['data']['files'][$error_path]['messages'][] = [
'message' => "Value of core_version_requirement: {$info['core_version_requirement']} is not compatible with Drupal 9.0.0. 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;
}
} 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;
}
}
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 for Drupal 9 compatibility 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'}) && !Semver::satisfies('9.0.0', $composer_json->require->{'drupal/core'})) {
$result['data']['files'][$extension
->getPath() . '/composer.json']['messages'][] = [
'message' => "The drupal/core requirement is not Drupal 9 compatible. Either remove it or update it to be compatible with Drupal 9. 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;
}
}
foreach ($result['data']['files'] as $path => &$errors) {
if (!empty($errors['messages'])) {
foreach ($errors['messages'] as &$error) {
[
$message,
$category,
] = $this
->categorizeMessage($error['message'], $extension);
$error['message'] = $message;
$error['upgrade_status_category'] = $category;
@$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']++;
}
}
}
}
if (!empty($extension->info['project'])) {
try {
$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']);
}
}
} catch (\Exception $e) {
$this->logger
->error($e
->getMessage());
}
}
$this->scanResultStorage
->set($extension
->getName(), json_encode($result));
}
protected function analyzeTwigTemplates($directory) {
return (new \Twig_Util_DeprecationCollector($this->twigEnvironment))
->collectDir($directory, '.html.twig');
}
protected function prepareTempDirectory() {
$success = $this->fileSystem
->prepareDirectory($this->temporaryDirectory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
if (!$success) {
throw new \Exception('Unable to create temporary directory for Upgrade Status at ' . $this->temporaryDirectory);
}
$phpstan_cache_directory = $this->temporaryDirectory . '/phpstan';
$success = $this->fileSystem
->prepareDirectory($phpstan_cache_directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
if (!$success) {
throw new \Exception('Unable to create temporary directory for PHPStan at ' . $phpstan_cache_directory);
}
}
protected function createModifiedNeonFile() {
$module_path = DRUPAL_ROOT . '/' . drupal_get_path('module', 'upgrade_status');
$config = file_get_contents($module_path . '/deprecation_testing.neon');
$config = str_replace('parameters:', "parameters:\n\ttmpDir: '" . $this->temporaryDirectory . '/phpstan' . "'\n" . "\tdrupal:\n\t\tdrupal_root: '" . DRUPAL_ROOT . "'", $config);
$config .= "\nincludes:\n\t- '" . $this->vendorPath . "/mglaman/phpstan-drupal/extension.neon'\n\t- '" . $this->vendorPath . "/phpstan/phpstan-deprecation-rules/rules.neon'\n";
$success = file_put_contents($this->phpstanNeonPath, $config);
if (!$success) {
throw new \Exception('Unable to write configuration for PHPStan to ' . $this->phpstanNeonPath);
}
}
protected function categorizeMessage(string $error, Extension $extension) {
$error = preg_replace('!:\\s+(in|as of)!', '. Deprecated \\1', $error);
$error = preg_replace('!(u|U)se \\\\Drupal!', '\\1se Drupal', $error);
$error = str_replace("\n", ' ', $error);
$version = '';
if (preg_match('!\\\\(Web|)TestBase. Deprecated in [Dd]rupal[ :]8.8.0 !', $error)) {
$version = '8.6.0';
$error .= " Replacement available from drupal:8.6.0.";
}
elseif (preg_match('!Deprecated (in|as of) [Dd]rupal[ :](8.\\d)!', $error, $version_found)) {
$version = $version_found[2];
}
$category = 'uncategorized';
if (!empty($version)) {
if (!empty($extension->info['project'])) {
if (version_compare($version, self::CORE_MINOR_OLDEST_SUPPORTED) <= 0) {
$category = 'old';
}
else {
$category = 'later';
}
}
else {
if (version_compare($version, \Drupal::VERSION) <= 0) {
$category = 'safe';
}
else {
$category = 'later';
}
}
}
if ($this
->isRectorCovered($error)) {
$category = 'rector';
}
if (preg_match('!(will be|is) removed (before|from) [Dd]rupal[ :](10.\\d)!', $error)) {
$category = 'ignore';
}
return [
$error,
$category,
];
}
protected function isRectorCovered($string) {
$rector_covered = [
'Call to deprecated function drupal_set_message(). Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\Messenger\\MessengerInterface::addMessage() instead.',
'Call to deprecated method entityManager() of class Drupal. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal::entityTypeManager() instead in most cases. If the needed method is not on \\Drupal\\Core\\Entity\\EntityTypeManagerInterface, see the deprecated \\Drupal\\Core\\Entity\\EntityManager to find the correct interface or service.',
'Call to deprecated method entityManager() of class Drupal\\Core\\Controller\\ControllerBase. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Most of the time static::entityTypeManager() is supposed to be used instead.',
'Call to deprecated function db_insert(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Instead, get a database connection injected into your service from the container and call insert() on it. For example,',
'Call to deprecated function db_select(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Instead, get a database connection injected into your service from the container and call select() on it. For example,',
'Call to deprecated function db_query(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Instead, get a database connection injected into your service from the container and call query() on it. For example,',
'Call to deprecated function file_prepare_directory(). Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::prepareDirectory().',
'Call to deprecated method getMock() of class Drupal\\Tests\\BrowserTestBase. Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\Tests\\PhpunitCompatibilityTrait::createMock() instead.',
'Call to deprecated method getMock() of class Drupal\\KernelTests\\KernelTestBase. Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\Tests\\PhpunitCompatibilityTrait::createMock() instead.',
'Call to deprecated method getMock() of class Drupal\\Tests\\UnitTestCase. Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\Tests\\PhpunitCompatibilityTrait::createMock() instead.',
'Call to deprecated method url() of class Drupal. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Instead create a \\Drupal\\Core\\Url object directly, for example using Url::fromRoute().',
'Call to deprecated function format_date(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal::service(\'date.formatter\')->format().',
'Call to deprecated method strtolower() of class Drupal\\Component\\Utility\\Unicode. Deprecated in drupal:8.6.0 and is removed from drupal:9.0.0. Use mb_strtolower() instead.',
'Call to deprecated constant FILE_CREATE_DIRECTORY: Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::CREATE_DIRECTORY.',
'Call to deprecated constant FILE_EXISTS_REPLACE: Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::EXISTS_REPLACE.',
'Call to deprecated method l() of class Drupal. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\Link::fromTextAndUrl() instead.',
'Call to deprecated function drupal_render(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use the',
'Call to deprecated function drupal_render_root(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\Render\\RendererInterface::renderRoot() instead.',
'Call to deprecated function file_unmanaged_save_data(). Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::saveData().',
'Call to deprecated constant FILE_MODIFY_PERMISSIONS: Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::MODIFY_PERMISSIONS.',
'Call to deprecated function db_delete(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Instead, get a database connection injected into your service from the container and call delete() on it. For example,',
'Call to deprecated function entity_get_form_display(). Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use EntityDisplayRepositoryInterface::getFormDisplay() instead.',
'Call to deprecated function entity_get_display(). Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use EntityDisplayRepositoryInterface::getViewDisplay() instead.',
'Call to deprecated const REQUEST_TIME. Deprecated in drupal:8.3.0 and is removed from drupal:10.0.0. Use Drupal::time()->getRequestTime().',
'Call to deprecated method urlInfo() of class Drupal\\Core\\Entity\\EntityInterface. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\Entity\\EntityInterface::toUrl() instead.',
'Call to deprecated function file_scan_directory(). Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::scanDirectory() instead.',
'Call to deprecated function file_default_scheme(). Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use Drupal::config(\'system.file\')->get(\'default_scheme\') instead.',
'Call to deprecated function db_update(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Instead, get a database connection injected into your service from the container and call update() on it. For example,',
'Call to deprecated method strtolower() of class Drupal\\Component\\Utility\\Unicode. Deprecated in drupal:8.6.0 and is removed from drupal:9.0.0. Use mb_strtolower() instead.',
'Call to deprecated method strlen() of class Drupal\\Component\\Utility\\Unicode. Deprecated in drupal:8.6.0 and is removed from drupal:9.0.0. Use mb_strlen() instead.',
'Call to deprecated method link() of class Drupal\\Core\\Entity\\EntityInterface. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\EntityInterface::toLink()->toString() instead.',
'Call to deprecated function entity_load(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use the entity type storage\'s load() method.',
'Call to deprecated function node_load(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\node\\Entity\\Node::load().',
'Call to deprecated function file_load(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\file\\Entity\\File::load().',
'Call to deprecated function user_load(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\user\\Entity\\User::load().',
'Call to deprecated function file_directory_temp(). Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::getTempDirectory() instead.',
'Call to deprecated function file_directory_os_temp(). Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use Drupal\\Component\\FileSystem\\FileSystem::getOsTemporaryDirectory().',
'Call to deprecated function drupal_realpath(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystem::realpath().',
'Call to deprecated function file_uri_target(). Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\StreamWrapper\\StreamWrapperManagerInterface::getTarget() instead.',
'Call to deprecated method format() of class Drupal\\Component\\Utility\\SafeMarkup. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\\Component\\Render\\FormattableMarkup.',
'Call to deprecated constant FILE_EXISTS_RENAME: Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use Drupal\\Core\\File\\FileSystemInterface::EXISTS_RENAME.',
'Call to deprecated function entity_create(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use The method overriding Entity::create() for the entity type, e.g. \\Drupal\\node\\Entity\\Node::create() if the entity type is known. If the entity type is variable, use the entity storage\'s create() method to construct a new entity:',
'Call to deprecated constant DATETIME_STORAGE_TIMEZONE: Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\datetime\\Plugin\\Field\\FieldType\\DateTimeItemInterface::STORAGE_TIMEZONE instead.',
'Call to deprecated constant DATETIME_DATETIME_STORAGE_FORMAT: Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\datetime\\Plugin\\Field\\FieldType\\DateTimeItemInterface::DATETIME_STORAGE_FORMAT instead.',
'Call to deprecated constant DATETIME_DATE_STORAGE_FORMAT: Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use Drupal\\datetime\\Plugin\\Field\\FieldType\\DateTimeItemInterface::DATE_STORAGE_FORMAT instead.',
];
return in_array($string, $rector_covered) || strpos($string, 'Call to deprecated method l() of class Drupal') === 0;
}
private function getSubExtensionInfoFiles(string $path) {
$files = [];
foreach (glob($path . '/*.info.yml') as $file) {
$parts = explode('.', basename($file));
if (count($parts) == 3) {
$files[] = $file;
}
}
foreach (glob($path . '/*', GLOB_ONLYDIR | GLOB_NOSORT) as $dir) {
$files = array_merge($files, $this
->getSubExtensionInfoFiles($dir));
}
return $files;
}
}