DeprecationAnalyzer.php 42.1 KB
Newer Older
1
2
3
4
<?php

namespace Drupal\upgrade_status;

5
use Drupal\Component\Datetime\TimeInterface;
6
7
use Drupal\Component\Serialization\Yaml;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
8
use Drupal\Core\Extension\Extension;
9
use Drupal\Core\File\FileSystemInterface;
10
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
11
use Drupal\Core\Template\TwigEnvironment;
12
use DrupalFinder\DrupalFinder;
13
use GuzzleHttp\Client;
14
use Psr\Log\LoggerInterface;
15
use Twig\Util\DeprecationCollector;
16
use Twig\Util\TemplateDirIterator;
17

18
final class DeprecationAnalyzer {
19
20

  /**
21
   * Upgrade status scan result storage.
22
   *
23
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
24
   */
25
  protected $scanResultStorage;
26
27

  /**
28
29
   * The logger service.
   *
30
31
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
32
33
  protected $logger;

34
  /**
35
36
37
   * Path to the PHPStan neon configuration.
   *
   * @var string
38
   */
39
  protected $phpstanNeonPath;
40

41
  /**
42
43
   * Path to the vendor directory.
   *
44
45
   * @var string
   */
46
  protected $vendorPath;
47

48
49
50
51
52
53
54
  /**
   * Path to the binaries.
   *
   * @var string
   */
  protected $binPath;

55
  /**
56
   * Temporary directory to use for running phpstan.
57
   *
58
   * @var string
59
   */
60
  protected $temporaryDirectory;
61

62
63
64
65
66
67
68
  /**
   * HTTP Client for drupal.org API calls.
   *
   * @var \GuzzleHttp\Client
   */
  protected $httpClient;

69
70
71
72
73
74
75
  /**
   * File system service.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

76
77
78
79
80
81
82
  /**
   * The Twig environment.
   *
   * @var \Drupal\Core\Template\TwigEnvironment
   */
  protected $twigEnvironment;

83
84
85
86
87
88
89
  /**
   * The library deprecation analyzer.
   *
   * @var \Drupal\upgrade_status\LibraryDeprecationAnalyzer
   */
  protected $libraryDeprecationAnalyzer;

90
91
92
93
94
95
96
  /**
   * The theme function deprecation analyzer.
   *
   * @var \Drupal\upgrade_status\ThemeFunctionDeprecationAnalyzer
   */
  protected $themeFunctionDeprecationAnalyzer;

97
98
99
100
101
102
103
  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected $time;

104
105
106
107
108
109
110
111
112
  /**
   * Drupal project finder.
   *
   * @var \DrupalFinder\DrupalFinder
   */
  protected $finder;

  /**
   * Whether the analyzer environment is initialized.
113
   *
114
115
116
117
   * @var bool
   */
  protected $environmentInitialized = FALSE;

118
  /**
119
   * Constructs a deprecation analyzer.
120
   *
121
122
   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory
   *   The key/value factory.
123
124
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger.
125
126
   * @param \GuzzleHttp\Client $http_client
   *   HTTP client.
127
128
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   File system service.
129
130
   * @param \Drupal\Core\Template\TwigEnvironment $twig_environment
   *   The Twig environment.
131
   * @param \Drupal\upgrade_status\LibraryDeprecationAnalyzer $library_deprecation_analyzer
132
   *   The library deprecation analyzer.
133
   * @param \Drupal\upgrade_status\ThemeFunctionDeprecationAnalyzer $theme_function_deprecation_analyzer
134
   *   The theme function deprecation analyzer.
135
136
   * @param \Drupal\Component\Datetime\TimeInterface $time
   *   The time service.
137
138
   */
  public function __construct(
139
    KeyValueFactoryInterface $key_value_factory,
140
    LoggerInterface $logger,
141
    Client $http_client,
142
    FileSystemInterface $file_system,
143
    TwigEnvironment $twig_environment,
144
    LibraryDeprecationAnalyzer $library_deprecation_analyzer,
145
146
    ThemeFunctionDeprecationAnalyzer $theme_function_deprecation_analyzer,
    TimeInterface $time
147
  ) {
148
    $this->scanResultStorage = $key_value_factory->get('upgrade_status_scan_results');
149
    $this->logger = $logger;
150
    $this->httpClient = $http_client;
151
    $this->fileSystem = $file_system;
152
    $this->twigEnvironment = $twig_environment;
153
    $this->libraryDeprecationAnalyzer = $library_deprecation_analyzer;
154
    $this->themeFunctionDeprecationAnalyzer = $theme_function_deprecation_analyzer;
155
    $this->time = $time;
156
  }
157

158
159
  /**
   * Initialize the external environment.
160
161
   *
   * @throws \Exception
162
163
164
165
166
167
168
169
170
171
172
173
174
   *   In case initialization failed. The analyzer will not work in this case.
   */
  public function initEnvironment() {
    if (!empty($this->environmentInitialized)) {
      // Already successfully initialized, no need to do it again.
      return;
    }

    $this->finder = new DrupalFinder();
    $this->finder->locateRoot(DRUPAL_ROOT);

    $this->vendorPath = $this->finder->getVendorDir();
    $this->binPath = $this->findBinPath();
175

176
177
178
179
180
181
182
183
184
185
186
    if (function_exists('file_directory_temp')) {
      // This is fallback code for 8.7.x and below. It's not called on later
      // versions, so we don't nee to "fix" it.
      // @noRector
      // @phpstan-ignore-next-line
      $system_temporary = file_directory_temp();
    }
    else {
      $system_temporary = $this->fileSystem->getTempDirectory();
    }
    $this->temporaryDirectory = $system_temporary . '/upgrade_status';
187
    if (!file_exists($this->temporaryDirectory)) {
188
189
      $this->prepareTempDirectory();
    }
190

191
    $this->phpstanNeonPath = $this->temporaryDirectory . '/deprecation_testing.neon';
192
    $this->createModifiedNeonFile();
193
194

    $this->environmentInitialized = TRUE;
195
196
  }

197
  /**
198
   * Finds bin-dir location.
199
   *
200
201
202
203
   * This can be set in composer.json via `bin-dir` config and may not be
   * inside the vendor directory. The logic somewhat duplicates
   * DrupalFinder's vendor directory detection for best developer guidance
   * in case of errors.
204
205
206
207
208
   *
   * @return string
   *   Bin directory path if found.
   *
   * @throws \Exception
209
   */
210
  protected function findBinPath() {
211
212
213
214
215
216
217
    $composer_name = trim(getenv('COMPOSER')) ?: 'composer.json';
    $composer_json_path = $this->finder->getComposerRoot() . '/' . $composer_name;
    if ($composer_json_path && file_exists($composer_json_path)) {
      $json = json_decode(file_get_contents($composer_json_path), TRUE);
      if (is_null($json) || !is_array($json)) {
        throw new \Exception('Unable to decode composer information from ' . $composer_json_path . '.');
      }
218
219
    }
    else {
220
      throw new \Exception('The composer.json file was not found at ' . $composer_json_path . '.');
221
222
    }

223
224
225
226
227
228
229
230
231
    // If a bin-dir is specified, that is most specific.
    if (isset($json['config']['bin-dir'])) {
      $binPath = $this->finder->getComposerRoot() . '/' . $json['config']['bin-dir'];
      if (file_exists($binPath . '/phpstan')) {
        return $binPath;
      }
      else {
        throw new \Exception('The PHPStan binary was not found in the bin-dir specified by ' . $composer_json_path . '. Attempted: ' . $binPath . '/phpstan.');
      }
232
    }
233

234
235
236
    // If a vendor-dir is specified, that is slightly less specific.
    if (isset($json['config']['vendor-dir'])) {
      $binPath = $this->finder->getComposerRoot() . '/' . $json['config']['vendor-dir'] . '/bin';
237
238
239
240
      if (file_exists($binPath . '/phpstan')) {
        return $binPath;
      }
      else {
241
        throw new \Exception('The PHPStan binary was not found in the vendor-dir specified by ' . $composer_json_path . '. Attempted: ' . $binPath . '/phpstan.');
242
      }
243
    }
244

245
246
247
248
249
250
251
    // Try the assumed default vendor directory as a last resort.
    $binPath = $this->finder->getComposerRoot() . '/vendor/bin';
    if (file_exists($binPath . '/phpstan')) {
      return $binPath;
    }

    throw new \Exception('The PHPStan binary was not found in the default vendor directory based on the location of ' . $composer_json_path . '. You may need to configure a vendor-dir in composer.json. See https://getcomposer.org/doc/06-config.md#vendor-dir. Attempted: ' . $binPath . '/phpstan.');
252
253
  }

254
  /**
255
   * Analyze the codebase of an extension including all its sub-components.
256
257
   *
   * @param \Drupal\Core\Extension\Extension $extension
258
   *   The extension to analyze.
259
260
261
   *
   * @return null
   *   Errors are logged to the logger, data is stored to keyvalue storage.
262
   */
263
  public function analyze(Extension $extension) {
264
265
266
267
268
269
    try {
      $this->initEnvironment();
    }
    catch (\Exception $e) {
      // Should not get here as integrations are expected to invoke
      // initEnvironment() first by itself to ensure the environment
270
      // is going to work when needed (and inform users about any
271
272
273
274
275
      // issues). That said, if they did not do that and there was
      // no issue with the environment, then they are lucky.
      return;
    }

276
    $project_dir = DRUPAL_ROOT . '/' . $extension->getPath();
277
278
    $this->logger->notice('Processing %path.', ['%path' => $project_dir]);

279
    $output = [];
280
281
282
283
    $error_filename = $this->temporaryDirectory . '/phpstan_error_output';
    $command = $this->binPath . '/phpstan analyse --error-format=json -c ' . $this->phpstanNeonPath . ' ' . $project_dir . ' 2> ' . $error_filename;
    exec($command, $output);

284
    $json = json_decode(implode('', $output), TRUE);
285
    if (!isset($json['files']) || !is_array($json['files'])) {
286
287
288
289
290
291
292
       $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)]);
293
294
       $json = [
         'files' => [
295
296
297
298
           // Add a failure message with the nonexistent 'PHPStan failed'
           // filename, so the error conforms to the expected format.
           'PHPStan failed' => [
             'messages' => [
299
               [
300
                 'message' => $formatted_error,
301
302
                 'line' => 0,
               ],
303
304
305
306
307
308
309
             ],
           ]
         ],
         'totals' => [
           'errors' => 1,
           'file_errors' => 1,
         ],
310
       ];
311
    }
312
    $result = [
313
      'date' => $this->time->getRequestTime(),
314
      'data' => $json,
315
    ];
316

317
    $twig_deprecations = $this->analyzeTwigTemplates($extension->getPath());
318
319
    foreach ($twig_deprecations as $twig_deprecation) {
      preg_match('/\s([a-zA-Z0-9\_\-\/]+.html\.twig)\s/', $twig_deprecation, $file_matches);
320
      preg_match('/\s(\d+).?$/', $twig_deprecation, $line_matches);
321
      $twig_deprecation = preg_replace('! in (.+)\.twig at line \d+\.!', '.', $twig_deprecation);
322
      $twig_deprecation .= ' See https://drupal.org/node/3071078.';
323
324
325
      $result['data']['files'][$file_matches[1]]['messages'][] = [
        'message' => $twig_deprecation,
        'line' => $line_matches[1] ?: 0,
326
327
328
329
330
      ];
      $result['data']['totals']['errors']++;
      $result['data']['totals']['file_errors']++;
    }

331
332
333
334
335
336
337
338
339
340
    $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']++;
    }

341
342
343
344
345
346
347
348
349
350
    $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']++;
    }

351
    // Assume this project is ready for the next major core version unless proven otherwise.
352
353
    $result['data']['totals']['upgrade_status_split']['declared_ready'] = TRUE;

354
355
356
    $info_files = $this->getSubExtensionInfoFiles($project_dir);
    foreach ($info_files as $info_file) {
      try {
357
358

        // Manually add on info file incompatibility to results. Reading
359
360
361
362
363
364
365
366
367
        // .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);
368
369
370
371
372
373
374
375
376
377
378
379
380

        // 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']++;
          }
        }

381
382
        if (!isset($info['core_version_requirement'])) {
          $result['data']['files'][$error_path]['messages'][] = [
383
            'message' => "Add core_version_requirement: ^8 || ^9 to designate that the module is compatible with Drupal 9. See https://drupal.org/node/3070687.",
384
385
386
387
388
389
            'line' => 0,
          ];
          $result['data']['totals']['errors']++;
          $result['data']['totals']['file_errors']++;
          $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
        }
390
        elseif (!ProjectCollector::isCompatibleWithNextMajorDrupal($info['core_version_requirement'])) {
391
          $result['data']['files'][$error_path]['messages'][] = [
392
            '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.",
393
394
395
396
397
398
            'line' => 0,
          ];
          $result['data']['totals']['errors']++;
          $result['data']['totals']['file_errors']++;
          $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
        }
399

400
401
402
      } catch (InvalidDataTypeException $e) {
        $result['data']['files'][$error_path]['messages'][] = [
          'message' => 'Parse error. ' . $e->getMessage(),
403
404
405
406
          'line' => 0,
        ];
        $result['data']['totals']['errors']++;
        $result['data']['totals']['file_errors']++;
407
        $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
408
      }
409
410
411
412

      // 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.
413
414
    }

415
416
417
418
    // 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)) {
419
        $result['data']['files'][$extension->getPath() . '/composer.json']['messages'][] = [
420
          '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.",
421
422
423
424
          'line' => 0,
        ];
        $result['data']['totals']['errors']++;
        $result['data']['totals']['file_errors']++;
425
        $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
426
      }
427
      elseif (!empty($composer_json->require->{'drupal/core'}) && !projectCollector::isCompatibleWithNextMajorDrupal($composer_json->require->{'drupal/core'})) {
428
        $result['data']['files'][$extension->getPath() . '/composer.json']['messages'][] = [
429
          '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.",
430
431
432
433
          'line' => 0,
        ];
        $result['data']['totals']['errors']++;
        $result['data']['totals']['file_errors']++;
434
        $result['data']['totals']['upgrade_status_split']['declared_ready'] = FALSE;
435
      }
436
437
438
439
440
441
442
443
444
      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;
      }
445
446
    }

447
448
449
    // Assume next step is to relax (there were no errors found).
    $result['data']['totals']['upgrade_status_next'] = ProjectCollector::NEXT_RELAX;

450
    foreach ($result['data']['files'] as $path => &$errors) {
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
      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;
        }
        // If the category was not rector, if the next step is still to
        // relax, modify that to fix manually.
        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
472
473
        // later for compatibility with the next major version. Issues in the
        // 'ignore' category are intentionally not counted in either.
474
475
476
477
478
479
        @$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']++;
480
481
482
483
        }
      }
    }

484
    // For contributed projects, attempt to grab upgrade plan information.
485
    if (!empty($extension->info['project'])) {
486
487
488
489
490
491
492
493
494
495
496
497
      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
          }
498
499
        }
      }
500
501
502
      catch (\Exception $e) {
        $this->logger->error($e->getMessage());
      }
503
504
    }

505
    // Store the analysis results in our storage bin.
506
    $this->scanResultStorage->set($extension->getName(), $result);
herczogzoltan's avatar
herczogzoltan committed
507
508
  }

509
510
511
512
513
514
515
516
517
  /**
   * Analyzes twig templates for calls of deprecated code.
   *
   * @param $directory
   *   The directory which Twig templates should be analyzed.
   *
   * @return array
   */
  protected function analyzeTwigTemplates($directory) {
518
519
520
521
522
    $iterator = new TemplateDirIterator(
      new TwigRecursiveIterator($directory)
    );
    return (new DeprecationCollector($this->twigEnvironment))
      ->collect($iterator);
523
524
  }

525
  /**
526
   * Prepare temporary directories for Upgrade Status.
527
   *
528
529
530
   * The created directories in Drupal's temporary directory are needed to
   * dynamically set a temporary directory for PHPStan's cache in the neon file
   * provided by Upgrade Status.
531
   *
532
533
   * @throws \Exception
   *   If creating the temporary directory failed.
534
535
   */
  protected function prepareTempDirectory() {
536
    $success = $this->fileSystem->prepareDirectory($this->temporaryDirectory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
537
    if (!$success) {
538
      throw new \Exception('Unable to create temporary directory for Upgrade Status at ' . $this->temporaryDirectory);
539
540
    }

541
    $phpstan_cache_directory = $this->temporaryDirectory . '/phpstan';
542
    $success = $this->fileSystem->prepareDirectory($phpstan_cache_directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
543
    if (!$success) {
544
      throw new \Exception('Unable to create temporary directory for PHPStan at ' . $phpstan_cache_directory);
545
546
547
548
549
550
    }
  }

  /**
   * Creates the final config file in the temporary directory.
   *
551
552
   * @throws \Exception
   *   If the PHPStan configuration file cannot be written.
553
554
   */
  protected function createModifiedNeonFile() {
555
    $module_path = DRUPAL_ROOT . '/' . drupal_get_path('module', 'upgrade_status');
556
    $config = file_get_contents($module_path . '/deprecation_testing_template.neon');
557
558
    $config = str_replace(
      'parameters:',
559
      "parameters:\n\ttmpDir: '" . $this->temporaryDirectory . '/phpstan' . "'\n" .
560
561
562
        "\tdrupal:\n\t\tdrupal_root: '" . DRUPAL_ROOT . "'",
      $config
    );
563
564

    if (!class_exists('PHPStan\ExtensionInstaller\GeneratedConfig')) {
565
566
567
568
569
570
      $extension_neon = $this->vendorPath . '/mglaman/phpstan-drupal/extension.neon';
      $rules_neon = $this->vendorPath . '/phpstan/phpstan-deprecation-rules/rules.neon';
      if (!file_exists($extension_neon) || !file_exists($rules_neon)) {
        throw new \Exception('Vendor source files were not found. You may need to configure a vendor-dir in composer.json. See https://getcomposer.org/doc/06-config.md#vendor-dir. Missing ' . $extension_neon . ' and ' . $rules_neon . '.');
      }
      $config .= "\nincludes:\n\t- '" . $extension_neon . "'\n\t- '" . $rules_neon . "'\n";
571
    }
572

573
574
    $success = file_put_contents($this->phpstanNeonPath, $config);
    if (!$success) {
575
      throw new \Exception('Unable to write configuration for PHPStan to ' . $this->phpstanNeonPath . '.');
576
    }
577
578
  }

579
580
581
582
583
584
585
586
587
588
589
590
591
  /**
   * Annotate and categorize the error message.
   *
   * @param string $error
   *   Error message as identified by phpstan.
   * @param \Drupal\Core\Extension\Extension $extension
   *   Extension where the error was found.
   *
   * @return array
   *   Two item array. The reformatted error and the category.
   */
  protected function categorizeMessage(string $error, Extension $extension) {
    // Make the error more readable in case it has the deprecation text.
592
    $error = preg_replace('!\s+!', ' ', $error);
593
    $error = preg_replace('!:\s+(in|as of)!', '. Deprecated \1', $error);
594
    $error = preg_replace('!(u|U)se \\\\Drupal!', '\1se Drupal', $error);
595

596
597
598
599
    // TestBase and WebTestBase replacements are available at least from Drupal
    // 8.6.0, so use that version number. Otherwise use the number from the
    // message.
    $version = '';
600
    if (preg_match('!\\\\(Web|)TestBase. Deprecated in [Dd]rupal[ :]8\.8\.0 !', $error)) {
601
602
603
      $version = '8.6.0';
      $error .= " Replacement available from drupal:8.6.0.";
    }
604
    elseif (preg_match('!Deprecated (in|as of) [Dd]rupal[ :](\d+\.\d)!', $error, $version_found)) {
605
606
607
      $version = $version_found[2];
    }

608
609
610
    // Set a default category for the messages we can't categorize.
    $category = 'uncategorized';

611
    if (!empty($version)) {
612
613
614
615
616
617
618

      // Categorize deprecations for contributed projects based on
      // community rules.
      if (!empty($extension->info['project'])) {
        // If the found deprecation is older or equal to the oldest
        // supported core version, it should be old enough to update
        // either way.
619
        if (version_compare($version, ProjectCollector::getOldestSupportedMinor()) <= 0) {
620
621
622
623
624
625
626
627
628
629
630
631
          $category = 'old';
        }
        // If the deprecation is not old and we are dealing with a contrib
        // module, the deprecation should be dealt with later.
        else {
          $category = 'later';
        }
      }
      // For custom projects, look at this site's version specifically.
      else {
        // If the found deprecation is older or equal to the current
        // Drupal version on this site, it should be safe to update.
632
        if (version_compare($version, \Drupal::VERSION) <= 0) {
633
634
635
636
637
638
639
640
          $category = 'safe';
        }
        else {
          $category = 'later';
        }
      }
    }

641
642
643
644
645
    // If the error is covered by rector, override the result.
    if ($this->isRectorCovered($error)) {
      $category = 'rector';
    }

646
647
648
649
650
651
    // If the deprecation is already for after the next Drupal major, put it in the
    // ignore category. This overwrites any categorization before intentionally.
    if (preg_match('!(will be|is) removed (before|from) [Dd]rupal[ :](\d+)\.!', $error, $version_removed)) {
      if ($version_removed[3] > ProjectCollector::getDrupalCoreMajorVersion() + 1) {
        $category = 'ignore';
      }
652
653
654
655
656
    }

    return [$error, $category];
  }

657
658
659
660
661
662
663
664
665
  /**
   * Checks whether an error message is covered by rector.
   *
   * @return bool
   */
  protected function isRectorCovered($string) {
    // Hardcoded lo-fi implementation for now. This should be the same as in
    // https://git.drupalcode.org/project/deprecation_status/-/blob/script/stats.php
    $rector_covered = [
666
      // 0.3.3
667
668
669
670
671
672
673
      '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().',
674
      '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.',
675
      '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.',
676
677
      '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().',
678
679

      // 0.4.0
680
681
682
683
684
685
686
      '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.',
687
688

      // 0.5.0
689
      '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().',
690
691

      // 0.5.1
692
693
      '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,',
694
695

      // 0.5.2
696
697
      '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.',
698
      'Call to deprecated const REQUEST_TIME. Deprecated in drupal:8.3.0 and is removed from drupal:10.0.0. Use Drupal::time()->getRequestTime().',
699
700
      '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.',
701
      '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.',
702
      '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,',
703
704
705
706
707
708
709
710
711
712
713
714
715

      // 0.5.3
      '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.',
716
717
718
719
720
721
722

      // 0.5.4
      '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.',
      // Covered below with the pattern.
      //'Call to deprecated method l() of class [redacted]. Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Link::fromTextAndUrl() instead.',
      '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:',
723
724
725
726
727
728
729
730

      // 0.5.5
      // No new rules

      // 0.5.6
      '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.',
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771

      // 0.10.0
      'Call to deprecated method getLowercaseLabel() of class Drupal\Core\Entity\EntityTypeInterface. Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Instead, you should call getSingularLabel(). See https://www.drupal.org/node/3075567',
      'Call to deprecated function entity_delete_multiple(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use the entity storage\'s \Drupal\Core\Entity\EntityStorageInterface::delete() method to delete multiple entities:',
      'Call to deprecated function entity_view(). Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use the entity view builder\'s view() method for creating a render array:',

      // 0.11.0
      // No new rules

      // 0.11.1
      'Call to deprecated method drupalPostForm() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use $this->submitForm() instead.',

      // yet unreleased
      'Call to deprecated method assertText() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use - $this->assertSession()->responseContains() for non-HTML responses, like XML or Json. - $this->assertSession()->pageTextContains() for HTML responses. Unlike the deprecated assertText(), the passed text should be HTML decoded, exactly as a human sees it in the browser.',
      'Call to deprecated method assertEqual() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use $this->assertEquals() instead.',
      'Call to deprecated method assertIdentical() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use $this->assertSame() instead.',
      'Call to deprecated method assertResponse() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->statusCodeEquals() instead.',
      'Call to deprecated method assertRaw() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseContains() instead.',
      'Call to deprecated method assertFieldByName() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->fieldExists() or $this->assertSession()->buttonExists() or $this->assertSession()->fieldValueEquals() instead.',
      'Call to deprecated method buildXPathQuery() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->buildXPathQuery() instead.',
      'Call to deprecated method assertHeader() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.3.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseHeaderEquals() instead.',
      'Call to deprecated method assertNoCacheTag() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.4.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseHeaderNotContains() instead.',
      'Call to deprecated method assertCacheTag() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseHeaderContains() instead.',
      'Call to deprecated method assertNoPattern() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.4.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseNotMatches() instead.',
      'Call to deprecated method assertPattern() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseMatches() instead.',
      'Call to deprecated method assertEscaped() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->assertEscaped() instead.',
      // assertNoEscaped() rule exists but no instance in contrib.
      'Call to deprecated method assertNotEqual() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use $this->assertNotEquals() instead.',
      'Call to deprecated method assertNotIdentical() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use $this->assertNotSame() instead.',
      // assertIdenticalObject() rule exists but no instance in contrib.
      'Call to deprecated method assert() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.0.0 and is removed from drupal:10.0.0. Use $this->assertTrue() instead.',
      'Call to deprecated method assertElementNotPresent() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->elementNotExists() instead.',
      'Call to deprecated method assertElementPresent() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->elementExists() instead.',
      'Call to deprecated method assertNoText() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use - $this->assertSession()->responseNotContains() for non-HTML responses, like XML or Json. - $this->assertSession()->pageTextNotContains() for HTML responses. Unlike the deprecated assertNoText(), the passed text should be HTML decoded, exactly as a human sees it in the browser.',
      'Call to deprecated method assertNoRaw() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->responseNotContains() instead.',
      'Call to deprecated method assertTitle() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->titleEquals() instead.',
      'Call to deprecated method assertNoLink() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->linkNotExists() instead.',
      'Call to deprecated method assertLink() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->linkExists() instead.',
      'Call to deprecated method assertLinkByHref() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->linkByHrefExists() instead.',
      'Call to deprecated method assertNoLinkByHref() of class Drupal\Tests\BrowserTestBase. Deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->linkByHrefNotExists() instead.',

772
    ];
773
774
    return
      in_array($string, $rector_covered) ||
775
      strpos($string, 'Call to deprecated method l() of class Drupal') === 0;
776
777
  }

778
779
780
781
782
783
784
785
786
787
  /**
   * Finds all .info.yml files for non-test extensions under a path.
   *
   * @param string $path
   *   Base path to find all info.yml files in.
   *
   * @return array
   *   A list of paths to .info.yml files found under the base path.
   */
  private function getSubExtensionInfoFiles(string $path) {
788
789
790
791
792
793
794
795
796
    $files = [];
    foreach(glob($path . '/*.info.yml') as $file) {
      // Make sure the filename matches rules for an extension. There may be
      // info.yml files in shipped configuration which would have more parts.
      $parts = explode('.', basename($file));
      if (count($parts) == 3) {
        $files[] = $file;
      }
    }
797
798
799
800
801
802
    foreach (glob($path . '/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
      $files = array_merge($files, $this->getSubExtensionInfoFiles($dir));
    }
    return $files;
  }

803
}