Undeclared::moduleActualDependencies()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 13
c 1
b 0
f 1
dl 0
loc 21
rs 9.5222
cc 5
nc 5
nop 2
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;
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
      assert(is_array($stmts));
143
    }
144
    catch (Error $e) {
145
      echo "Skipping ${path} for parse error: " . $e->getMessage();
146
      return [];
147
    }
148
149
    $traverser = new NodeTraverser();
150
    $traverser->addVisitor($visitor = new FunctionCallVisitor());
151
    $traverser->traverse($stmts);
152
    // Ignore builtin/extension functions.
153
    $pad = array_filter($visitor->pad, function ($name) {
154
      $isInternal = isset($this->qam->internalFunctions[$name]);
155
      return !$isInternal;
156
    }, ARRAY_FILTER_USE_KEY);
157
    return $pad;
158
  }
159
160
  /**
161
   * Build the list of actual module dependencies in all modules based on calls.
162
   *
163
   * @param string $name
164
   *   The name of the module for which to list dependencies.
165
   * @param array $calls
166
   *   The function calls performed by that module.
167
   *
168
   * @return array
169
   *   A map of modules names by module name.
170
   */
171
  protected function moduleActualDependencies(string $name, array $calls): array {
172
    $modules = [];
173
    foreach ($calls as $called => $lines) {
174
      try {
175
        $rf = new \ReflectionFunction($called);
176
      }
177
      catch (\ReflectionException $e) {
178
        $modules["(${called})"][$called] = $lines;
179
        continue;
180
      }
181
182
      // Drupal name-based magic.
183
      $module = basename($rf->getFileName(), '.module');
184
      if ($module !== $name) {
185
        if (!isset($modules[$module][$called])) {
186
          $modules[$module][$called] = [];
187
        }
188
        $modules[$module][$called] += $lines;
189
      }
190
    }
191
    return $modules;
192
  }
193
194
  /**
195
   * Build the list of module dependencies in all modules based on module info.
196
   *
197
   * @param string $name
198
   *   The name of the module for which to list dependencies.
199
   *
200
   * @return array
201
   *   A map of modules names by module name.
202
   */
203
  protected function moduleDeclaredDependencies(string $name): array {
204
    $ext = $this->elm->get($name);
205
    // XXX Undocumented API field.
206
    $deps = array_keys($ext->requires);
0 ignored issues
show
Bug introduced by
The property requires does not seem to exist on Drupal\Core\Extension\Extension.
Loading history...
207
    return $deps;
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_key($actual, array_flip($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