Passed
Pull Request — 8.x-1.x (#16)
by Frédéric G.
05:17
created

Undeclared::create()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 5
c 1
b 0
f 1
dl 0
loc 11
rs 10
cc 1
nc 1
nop 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Drupal\qa\Plugin\QaCheck\Dependencies;
6
7
use Drupal\Core\Extension\ModuleExtensionList;
8
use Drupal\qa\Data;
9
use Drupal\qa\Pass;
10
use Drupal\qa\Plugin\QaCheckBase;
11
use Drupal\qa\Plugin\QaCheckInterface;
12
use Drupal\qa\Plugin\QaCheckManager;
13
use Drupal\qa\Result;
14
use PhpParser\Error;
15
use PhpParser\NodeTraverser;
16
use PhpParser\ParserFactory;
17
use Symfony\Component\DependencyInjection\ContainerInterface;
18
19
/**
20
 * Undeclared checks undeclared module dependencies based on function calls.
21
 *
22
 * It only covers:
23
 * - function calls (not method calls),
24
 * - in .module files,
25
 * - to functions located in other module files.
26
 *
27
 * Later versions could go further.
28
 *
29
 * @QaCheck(
30
 *   id = "dependencies.undeclared",
31
 *   label = @Translation("Undeclared dependencies"),
32
 *   details = @Translation("This check finds modules doing cross-module function calls to other modules not declared as dependencies."),
33
 *   usesBatch = false,
34
 *   steps = 1,
35
 * )
36
 */
37
class Undeclared extends QaCheckBase implements QaCheckInterface {
38
39
  const NAME = 'dependencies.undeclared';
40
41
  /**
42
   * The extension.list.modules service.
43
   *
44
   * @var \Drupal\Core\Extension\ExtensionList
45
   */
46
  protected $elm;
47
48
  /**
49
   * A PHP-Parser parser.
50
   *
51
   * @var \PhpParser\Parser
52
   */
53
  protected $parser;
54
55
  /**
56
   * The plugin_manager.qa_check service.
57
   *
58
   * @var \Drupal\qa\Plugin\QaCheckManager
59
   */
60
  protected $qam;
61
62
  /**
63
   * Undeclared constructor.
64
   *
65
   * @param array $configuration
66
   *   The plugin configuration.
67
   * @param string $id
68
   *   The plugin ID.
69
   * @param array $definition
70
   *   The plugin definition.
71
   * @param \Drupal\Core\Extension\ModuleExtensionList $elm
72
   *   The extension.list.module service.
73
   * @param \Drupal\qa\Plugin\QaCheckManager $qam
74
   *   The plugin_manager.qa_check service.
75
   */
76
  public function __construct(
77
    array $configuration,
78
    string $id,
79
    array $definition,
80
    ModuleExtensionList $elm,
81
    QaCheckManager $qam
82
  ) {
83
    parent::__construct($configuration, $id, $definition);
84
    $this->elm = $elm;
85
    $this->qam = $qam;
86
87
    $this->qam->initInternalFunctions();
88
89
    // For the sake of evolution, ignore PHP5-only code.
90
    $this->parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
91
  }
92
93
  /**
94
   * {@inheritdoc}
95
   */
96
  public static function create(
97
    ContainerInterface $container,
98
    array $configuration,
99
    $id,
100
    $definition
101
  ) {
102
    $elm = $container->get('extension.list.module');
103
    assert($elm instanceof ModuleExtensionList);
104
    $qam = $container->get(Data::MANAGER);
105
    assert($qam instanceof QaCheckManager);
106
    return new static($configuration, $id, $definition, $elm, $qam);
107
  }
108
109
  /**
110
   * Build the list of module names regardless of module installation status.
111
   *
112
   * @return array
113
   *   A map of installed status by module name.
114
   */
115
  protected function getModulesToScan(): array {
116
    $list = $this->elm->getList() ?? [];
117
    $list = array_filter($list, function ($ext) {
118
      $isCore = substr($ext->getPath(), 0, 5) === 'core/';
119
      return !$isCore;
120
    });
121
    $list = array_flip(array_keys($list));
122
    $installed = $this->elm->getAllInstalledInfo();
123
    foreach ($list as $module => &$on) {
124
      $on = isset($installed[$module]);
125
    }
126
    return $list;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $list could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
127
  }
128
129
  /**
130
   * Build the list of function calls in a single source file.
131
   *
132
   * @param string $path
133
   *   The absolute path to the module file.
134
   *
135
   * @return array
136
   *   An array of function names.
137
   */
138
  protected function functionCalls(string $path): array {
139
    $code = file_get_contents($path);
140
    try {
141
      $stmts = $this->parser->parse($code);
142
    }
143
    catch (Error $e) {
144
      echo "Skipping ${path} for parse error: " . $e->getMessage();
145
      return [];
146
    }
147
148
    $traverser = new NodeTraverser();
149
    $traverser->addVisitor($visitor = new FunctionCallVisitor());
150
    $traverser->traverse($stmts);
0 ignored issues
show
Bug introduced by
It seems like $stmts can also be of type null; however, parameter $nodes of PhpParser\NodeTraverser::traverse() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

150
    $traverser->traverse(/** @scrutinizer ignore-type */ $stmts);
Loading history...
151
    $pad = array_unique($visitor->pad);
152
    // Ignore builtin/extension functions.
153
    $pad = array_filter($pad, function ($name) {
154
      return empty($this->qam->internalFunctions[$name]);
155
    });
156
    return $pad;
157
  }
158
159
  /**
160
   * Build the list of actual module dependencies in all modules based on calls.
161
   *
162
   * @param string $name
163
   *   The name of the module for which to list dependencies.
164
   * @param array $calls
165
   *   The function calls performed by that module.
166
   *
167
   * @return array
168
   *   A map of modules names by module name.
169
   */
170
  protected function moduleActualDependencies(string $name, array $calls): array {
171
    $modules = [];
172
    foreach ($calls as $called) {
173
      try {
174
        $rf = new \ReflectionFunction($called);
175
      }
176
      catch (\ReflectionException $e) {
177
        $modules[] = "(${called})";
178
        continue;
179
      }
180
181
      // Drupal name-based magic.
182
      $module = basename($rf->getFileName(), '.module');
183
      if ($module !== $name) {
184
        $modules[] = $module;
185
      }
186
    }
187
    return $modules;
188
  }
189
190
  /**
191
   * Build the list of module dependencies in all modules based on module info.
192
   *
193
   * @param string $name
194
   *   The name of the module for which to list dependencies.
195
   *
196
   * @return array
197
   *   A map of modules names by module name.
198
   */
199
  protected function moduleDeclaredDependencies(string $name): array {
200
    $info = $this->elm->getExtensionInfo($name);
201
    $deps = $info['dependencies'] ?? [];
202
    $res = [];
203
    foreach ($deps as $dep) {
204
      $ar = explode(":", $dep);
205
      $res[] = array_pop($ar);
206
    }
207
    return $res;
208
  }
209
210
  /**
211
   * Perform the undeclared calls check.
212
   *
213
   * @return \Drupal\qa\Result
214
   *   The result.
215
   */
216
  public function check(): Result {
217
    $modules = $this->getModulesToScan();
218
    $all = $this->elm->getList();
219
    $undeclared = [];
220
    foreach ($modules as $module => $installed) {
221
      $path = $all[$module]->getExtensionPathname();
222
      if (!$installed || empty($path)) {
223
        continue;
224
      }
225
      $calls = $this->functionCalls($path);
226
      $actual = $this->moduleActualDependencies($module, $calls);
227
      $declared = $this->moduleDeclaredDependencies($module);
228
      $missing = array_diff($actual, $declared);
229
      if (!empty($missing)) {
230
        $undeclared[$module] = $missing;
231
      }
232
    }
233
234
    return new Result('function_calls', empty($undeclared), $undeclared);
235
  }
236
237
  /**
238
   * {@inheritdoc}
239
   */
240
  public function run(): Pass {
241
    $pass = parent::run();
242
    $pass->record($this->check());
243
    $pass->life->end();
244
    return $pass;
245
  }
246
247
}
248