PublicCodeTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\Test\Integrity;
  7. use Magento\Framework\App\Utility\Files;
  8. /**
  9. * Tests @api annotated code integrity
  10. */
  11. class PublicCodeTest extends \PHPUnit\Framework\TestCase
  12. {
  13. /**
  14. * List of simple return types that are used in docblocks.
  15. * Used to check if type declared in a docblock of a method is a class or interface
  16. *
  17. * @var array
  18. */
  19. private $simpleReturnTypes = [
  20. '$this', 'void', 'string', 'int', 'bool', 'boolean', 'integer', 'null'
  21. ];
  22. /**
  23. * @var string[]|null
  24. */
  25. private $blockWhitelist;
  26. /**
  27. * Return whitelist class names
  28. *
  29. * @return string[]
  30. */
  31. private function getWhitelist(): array
  32. {
  33. if ($this->blockWhitelist === null) {
  34. $whiteListFiles = str_replace(
  35. '\\',
  36. '/',
  37. realpath(__DIR__) . '/_files/whitelist/public_code*.txt'
  38. );
  39. $whiteListItems = [];
  40. foreach (glob($whiteListFiles) as $fileName) {
  41. $whiteListItems = array_merge(
  42. $whiteListItems,
  43. file($fileName, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)
  44. );
  45. }
  46. $this->blockWhitelist = $whiteListItems;
  47. }
  48. return $this->blockWhitelist;
  49. }
  50. /**
  51. * Since blocks can be referenced from templates, they should be stable not to break theme customizations.
  52. * So all blocks should be @api annotated. This test checks that all blocks declared in layout files are public
  53. *
  54. * @param $layoutFile
  55. * @throws \ReflectionException
  56. * @dataProvider layoutFilesDataProvider
  57. */
  58. public function testAllBlocksReferencedInLayoutArePublic($layoutFile)
  59. {
  60. $nonPublishedBlocks = [];
  61. $xml = simplexml_load_file($layoutFile);
  62. $elements = $xml->xpath('//block | //referenceBlock') ?: [];
  63. /** @var $node \SimpleXMLElement */
  64. foreach ($elements as $node) {
  65. $class = (string) $node['class'];
  66. if ($class && \class_exists($class) && !in_array($class, $this->getWhitelist())) {
  67. $reflection = (new \ReflectionClass($class));
  68. if (strpos($reflection->getDocComment(), '@api') === false) {
  69. $nonPublishedBlocks[] = $class;
  70. }
  71. }
  72. }
  73. if (count($nonPublishedBlocks)) {
  74. $this->fail(
  75. "Layout file '$layoutFile' uses following blocks that are not marked with @api annotation:\n"
  76. . implode(",\n", array_unique($nonPublishedBlocks))
  77. );
  78. }
  79. }
  80. /**
  81. * Find all layout update files in magento modules and themes.
  82. *
  83. * @return array
  84. * @throws \Exception
  85. */
  86. public function layoutFilesDataProvider()
  87. {
  88. return Files::init()->getLayoutFiles([], true);
  89. }
  90. /**
  91. * We want to avoid situation when a type is marked public (@api annotated) but one of its methods
  92. * returns or accepts the value of non-public type.
  93. * This test walks through all public PHP types and makes sure that all their method arguments
  94. * and return values are public types.
  95. *
  96. * @param string $class
  97. * @throws \ReflectionException
  98. * @dataProvider publicPHPTypesDataProvider
  99. */
  100. public function testAllPHPClassesReferencedFromPublicClassesArePublic($class)
  101. {
  102. $nonPublishedClasses = [];
  103. $reflection = new \ReflectionClass($class);
  104. $filter = \ReflectionMethod::IS_PUBLIC;
  105. if ($reflection->isAbstract()) {
  106. $filter = $filter | \ReflectionMethod::IS_PROTECTED;
  107. }
  108. $methods = $reflection->getMethods($filter);
  109. foreach ($methods as $method) {
  110. if ($method->isConstructor()) {
  111. continue;
  112. }
  113. $nonPublishedClasses = $this->checkParameters($class, $method, $nonPublishedClasses);
  114. /* Taking into account docblock return types since this code
  115. is written on early php 7 when return types are not actively used */
  116. $returnTypes = [];
  117. if ($method->hasReturnType()) {
  118. if (!$method->getReturnType()->isBuiltin()) {
  119. $returnTypes = [trim($method->getReturnType()->__toString(), '?[]')];
  120. }
  121. } else {
  122. $returnTypes = $this->getReturnTypesFromDocComment($method->getDocComment());
  123. }
  124. $nonPublishedClasses = $this->checkReturnValues($class, $returnTypes, $nonPublishedClasses);
  125. }
  126. if (count($nonPublishedClasses)) {
  127. $this->fail(
  128. "Public type '" . $class . "' references following non-public types:\n"
  129. . implode("\n", array_unique($nonPublishedClasses))
  130. );
  131. }
  132. }
  133. /**
  134. * Retrieve list of all interfaces and classes in Magento codebase that are marked with @api annotation.
  135. * @return array
  136. * @throws \Exception
  137. */
  138. public function publicPHPTypesDataProvider()
  139. {
  140. $files = Files::init()->getPhpFiles(Files::INCLUDE_LIBS | Files::INCLUDE_APP_CODE);
  141. $result = [];
  142. foreach ($files as $file) {
  143. $fileContents = \file_get_contents($file);
  144. if (strpos($fileContents, '@api') !== false) {
  145. foreach ($this->getDeclaredClassesAndInterfaces($file) as $class) {
  146. if (!in_array($class->getName(), $this->getWhitelist())
  147. && (class_exists($class->getName()) || interface_exists($class->getName()))
  148. ) {
  149. $result[$class->getName()] = [$class->getName()];
  150. }
  151. }
  152. }
  153. }
  154. return $result;
  155. }
  156. /**
  157. * Retrieve list of classes and interfaces declared in the file
  158. *
  159. * @param string $file
  160. * @return \Zend\Code\Scanner\ClassScanner[]
  161. */
  162. private function getDeclaredClassesAndInterfaces($file)
  163. {
  164. $fileScanner = new \Magento\Setup\Module\Di\Code\Reader\FileScanner($file);
  165. return $fileScanner->getClasses();
  166. }
  167. /**
  168. * Check if a class is @api annotated
  169. *
  170. * @param \ReflectionClass $class
  171. * @return bool
  172. */
  173. private function isPublished(\ReflectionClass $class)
  174. {
  175. return strpos($class->getDocComment(), '@api') !== false;
  176. }
  177. /**
  178. * Simplified check of class relation.
  179. *
  180. * @param string $classNameA
  181. * @param string $classNameB
  182. * @return bool
  183. */
  184. private function areClassesFromSameVendor($classNameA, $classNameB)
  185. {
  186. $classNameA = ltrim($classNameA, '\\');
  187. $classNameB = ltrim($classNameB, '\\');
  188. $aVendor = substr($classNameA, 0, strpos($classNameA, '\\'));
  189. $bVendor = substr($classNameB, 0, strpos($classNameB, '\\'));
  190. return $aVendor === $bVendor;
  191. }
  192. /**
  193. * Check if the class belongs to the list of classes generated by Magento on demand.
  194. *
  195. * We don't need to check @api annotation coverage for generated classes
  196. *
  197. * @param string $className
  198. * @return bool
  199. */
  200. private function isGenerated($className)
  201. {
  202. return substr($className, -18) === 'ExtensionInterface' || substr($className, -7) === 'Factory';
  203. }
  204. /**
  205. * Retrieves list of method return types from method doc comment
  206. *
  207. * Introduced this method to abstract complexity of coping with types in "return" annotation
  208. *
  209. * @param string $docComment
  210. * @return array
  211. */
  212. private function getReturnTypesFromDocComment($docComment)
  213. {
  214. // TODO: add docblock namespace resolving using third-party library
  215. if (preg_match('/@return (\S*)/', $docComment, $matches)) {
  216. return array_map(
  217. 'trim',
  218. explode('|', $matches[1])
  219. );
  220. } else {
  221. return [];
  222. }
  223. }
  224. /**
  225. * Check method return values
  226. *
  227. * TODO: improve return type filtration
  228. *
  229. * @param string $class
  230. * @param array $returnTypes
  231. * @param array $nonPublishedClasses
  232. * @return mixed
  233. */
  234. private function checkReturnValues($class, array $returnTypes, array $nonPublishedClasses)
  235. {
  236. foreach ($returnTypes as $returnType) {
  237. if (!in_array($returnType, $this->simpleReturnTypes)
  238. && !$this->isGenerated($returnType)
  239. && \class_exists($returnType)
  240. ) {
  241. $returnTypeReflection = new \ReflectionClass($returnType);
  242. if (!$returnTypeReflection->isInternal()
  243. && $this->areClassesFromSameVendor($returnType, $class)
  244. && !$this->isPublished($returnTypeReflection)
  245. ) {
  246. $nonPublishedClasses[$returnType] = $returnType;
  247. }
  248. }
  249. }
  250. return $nonPublishedClasses;
  251. }
  252. /**
  253. * Check if all method parameters are public
  254. * @param string $class
  255. * @param \ReflectionMethod $method
  256. * @param array $nonPublishedClasses
  257. * @return array
  258. */
  259. private function checkParameters($class, \ReflectionMethod $method, array $nonPublishedClasses)
  260. {
  261. /* Ignoring docblocks for argument types */
  262. foreach ($method->getParameters() as $parameter) {
  263. if ($parameter->hasType()
  264. && !$parameter->getType()->isBuiltin()
  265. && !$this->isGenerated($parameter->getType()->__toString())
  266. ) {
  267. $parameterClass = $parameter->getClass();
  268. /*
  269. * We don't want to check integrity of @api coverage of classes
  270. * that belong to different vendors, because it is too complicated.
  271. * Example:
  272. * If Magento class references non-@api annotated class from Zend,
  273. * we don't want to fail test, because Zend is considered public by default,
  274. * and we don't care if Zend classes are @api-annotated
  275. */
  276. if (!$parameterClass->isInternal()
  277. && $this->areClassesFromSameVendor($parameterClass->getName(), $class)
  278. && !$this->isPublished($parameterClass)
  279. ) {
  280. $nonPublishedClasses[$parameterClass->getName()] = $parameterClass->getName();
  281. }
  282. }
  283. }
  284. return $nonPublishedClasses;
  285. }
  286. }