304 lines
10 KiB
PHP
Executable File
304 lines
10 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* Copyright © Magento, Inc. All rights reserved.
|
|
* See COPYING.txt for license details.
|
|
*/
|
|
namespace Magento\Test\Integrity;
|
|
|
|
use Exception;
|
|
use Magento\Framework\App\Utility\Files;
|
|
use Magento\Setup\Module\Di\Code\Reader\FileClassScanner;
|
|
use PHPUnit\Framework\TestCase;
|
|
use ReflectionClass;
|
|
use ReflectionMethod;
|
|
|
|
/**
|
|
* Tests @api annotated code integrity
|
|
*/
|
|
class PublicCodeTest extends TestCase
|
|
{
|
|
/**
|
|
* List of simple return types that are used in docblocks.
|
|
* Used to check if type declared in a docblock of a method is a class or interface
|
|
*
|
|
* @var array
|
|
*/
|
|
private $simpleReturnTypes = [
|
|
'$this', 'void', 'string', 'int', 'bool', 'boolean', 'integer', 'null'
|
|
];
|
|
|
|
/**
|
|
* @var string[]|null
|
|
*/
|
|
private $blockWhitelist;
|
|
|
|
/**
|
|
* Return whitelist class names
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private function getWhitelist(): array
|
|
{
|
|
if ($this->blockWhitelist === null) {
|
|
$whiteListFiles = str_replace(
|
|
'\\',
|
|
'/',
|
|
realpath(__DIR__) . '/_files/whitelist/public_code*.txt'
|
|
);
|
|
$whiteListItems = [];
|
|
foreach (glob($whiteListFiles) as $fileName) {
|
|
$whiteListItems[] = file($fileName, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
}
|
|
$this->blockWhitelist = array_merge([], ...$whiteListItems);
|
|
}
|
|
return $this->blockWhitelist;
|
|
}
|
|
|
|
/**
|
|
* Since blocks can be referenced from templates, they should be stable not to break theme customizations.
|
|
* So all blocks should be @api annotated. This test checks that all blocks declared in layout files are public
|
|
*
|
|
* @param $layoutFile
|
|
* @throws \ReflectionException
|
|
* @dataProvider layoutFilesDataProvider
|
|
*/
|
|
public function testAllBlocksReferencedInLayoutArePublic($layoutFile)
|
|
{
|
|
$nonPublishedBlocks = [];
|
|
$xml = simplexml_load_file($layoutFile);
|
|
$elements = $xml->xpath('//block | //referenceBlock') ?: [];
|
|
/** @var $node \SimpleXMLElement */
|
|
foreach ($elements as $node) {
|
|
$class = (string) $node['class'];
|
|
if ($class && \class_exists($class) && !in_array($class, $this->getWhitelist())) {
|
|
$reflection = (new ReflectionClass($class));
|
|
if (strpos($reflection->getDocComment(), '@api') === false) {
|
|
$nonPublishedBlocks[] = $class;
|
|
}
|
|
}
|
|
}
|
|
if (count($nonPublishedBlocks)) {
|
|
$this->fail(
|
|
"Layout file '$layoutFile' uses following blocks that are not marked with @api annotation:\n"
|
|
. implode(",\n", array_unique($nonPublishedBlocks))
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find all layout update files in magento modules and themes.
|
|
*
|
|
* @return array
|
|
* @throws Exception
|
|
*/
|
|
public function layoutFilesDataProvider()
|
|
{
|
|
return Files::init()->getLayoutFiles([], true);
|
|
}
|
|
|
|
/**
|
|
* We want to avoid situation when a type is marked public (@api annotated) but one of its methods
|
|
* returns or accepts the value of non-public type.
|
|
* This test walks through all public PHP types and makes sure that all their method arguments
|
|
* and return values are public types.
|
|
*
|
|
* @param string $class
|
|
* @throws \ReflectionException
|
|
* @dataProvider publicPHPTypesDataProvider
|
|
*/
|
|
public function testAllPHPClassesReferencedFromPublicClassesArePublic($class)
|
|
{
|
|
$nonPublishedClasses = [];
|
|
$reflection = new ReflectionClass($class);
|
|
$filter = ReflectionMethod::IS_PUBLIC;
|
|
if ($reflection->isAbstract()) {
|
|
$filter = $filter | ReflectionMethod::IS_PROTECTED;
|
|
}
|
|
$methods = $reflection->getMethods($filter);
|
|
foreach ($methods as $method) {
|
|
if ($method->isConstructor()) {
|
|
continue;
|
|
}
|
|
$nonPublishedClasses = $this->checkParameters($class, $method, $nonPublishedClasses);
|
|
/* Taking into account docblock return types since this code
|
|
is written on early php 7 when return types are not actively used */
|
|
$returnTypes = [];
|
|
if ($method->hasReturnType()) {
|
|
$methodReturnType = $method->getReturnType();
|
|
// For PHP 8.0 - ReflectionUnionType doesn't have isBuiltin method.
|
|
if (method_exists($methodReturnType, 'isBuiltin')
|
|
&& !$methodReturnType->isBuiltin()) {
|
|
$returnTypes = [trim($methodReturnType->getName(), '?[]')];
|
|
}
|
|
} else {
|
|
$returnTypes = $this->getReturnTypesFromDocComment($method->getDocComment());
|
|
}
|
|
$nonPublishedClasses = $this->checkReturnValues($class, $returnTypes, $nonPublishedClasses);
|
|
}
|
|
|
|
if (count($nonPublishedClasses)) {
|
|
$this->fail(
|
|
"Public type '" . $class . "' references following non-public types:\n"
|
|
. implode("\n", array_unique($nonPublishedClasses))
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve list of all interfaces and classes in Magento codebase that are marked with @api annotation.
|
|
*
|
|
* @return array
|
|
* @throws Exception
|
|
*/
|
|
public function publicPHPTypesDataProvider(): array
|
|
{
|
|
$files = Files::init()->getPhpFiles(Files::INCLUDE_LIBS | Files::INCLUDE_APP_CODE);
|
|
$result = [];
|
|
foreach ($files as $file) {
|
|
$fileContents = \file_get_contents($file);
|
|
if (strpos($fileContents, '@api') !== false) {
|
|
$fileClassScanner = new FileClassScanner($file);
|
|
$className = $fileClassScanner->getClassName();
|
|
|
|
if (!in_array($className, $this->getWhitelist())
|
|
&& (class_exists($className) || interface_exists($className))
|
|
) {
|
|
$result[$className] = [$className];
|
|
}
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Check if a class is @api annotated
|
|
*
|
|
* @param ReflectionClass $class
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function isPublished(ReflectionClass $class)
|
|
{
|
|
return strpos($class->getDocComment(), '@api') !== false;
|
|
}
|
|
|
|
/**
|
|
* Simplified check of class relation.
|
|
*
|
|
* @param string $classNameA
|
|
* @param string $classNameB
|
|
* @return bool
|
|
*/
|
|
private function areClassesFromSameVendor($classNameA, $classNameB)
|
|
{
|
|
$classNameA = ltrim($classNameA, '\\');
|
|
$classNameB = ltrim($classNameB, '\\');
|
|
$aVendor = substr($classNameA, 0, strpos($classNameA, '\\'));
|
|
$bVendor = substr($classNameB, 0, strpos($classNameB, '\\'));
|
|
return $aVendor === $bVendor;
|
|
}
|
|
|
|
/**
|
|
* Check if the class belongs to the list of classes generated by Magento on demand.
|
|
*
|
|
* We don't need to check @api annotation coverage for generated classes
|
|
*
|
|
* @param string $className
|
|
* @return bool
|
|
*/
|
|
private function isGenerated($className)
|
|
{
|
|
return substr($className, -18) === 'ExtensionInterface' || substr($className, -7) === 'Factory';
|
|
}
|
|
|
|
/**
|
|
* Retrieves list of method return types from method doc comment
|
|
*
|
|
* Introduced this method to abstract complexity of coping with types in "return" annotation
|
|
*
|
|
* @param string $docComment
|
|
* @return array
|
|
*/
|
|
private function getReturnTypesFromDocComment($docComment)
|
|
{
|
|
// TODO: add docblock namespace resolving using third-party library
|
|
if (preg_match('/@return (\S*)/', $docComment, $matches)) {
|
|
return array_map(
|
|
'trim',
|
|
explode('|', $matches[1])
|
|
);
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check method return values
|
|
*
|
|
* TODO: improve return type filtration
|
|
*
|
|
* @param string $class
|
|
* @param array $returnTypes
|
|
* @param array $nonPublishedClasses
|
|
* @return mixed
|
|
*/
|
|
private function checkReturnValues($class, array $returnTypes, array $nonPublishedClasses)
|
|
{
|
|
foreach ($returnTypes as $returnType) {
|
|
if (!in_array($returnType, $this->simpleReturnTypes)
|
|
&& !$this->isGenerated($returnType)
|
|
&& \class_exists($returnType)
|
|
) {
|
|
$returnTypeReflection = new ReflectionClass($returnType);
|
|
if (!$returnTypeReflection->isInternal()
|
|
&& $this->areClassesFromSameVendor($returnType, $class)
|
|
&& !$this->isPublished($returnTypeReflection)
|
|
) {
|
|
$nonPublishedClasses[$returnType] = $returnType;
|
|
}
|
|
}
|
|
}
|
|
return $nonPublishedClasses;
|
|
}
|
|
|
|
/**
|
|
* Check if all method parameters are public
|
|
*
|
|
* @param string $class
|
|
* @param ReflectionMethod $method
|
|
* @param array $nonPublishedClasses
|
|
*
|
|
* @return array
|
|
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
|
|
*/
|
|
private function checkParameters($class, ReflectionMethod $method, array $nonPublishedClasses)
|
|
{
|
|
/* Ignoring docblocks for argument types */
|
|
foreach ($method->getParameters() as $parameter) {
|
|
$parameterType = $parameter->getType();
|
|
if ($parameterType
|
|
&& method_exists($parameterType, 'isBuiltin')
|
|
&& !$parameterType->isBuiltin()
|
|
&& !$this->isGenerated($parameterType->getName())
|
|
) {
|
|
$parameterClass = new ReflectionClass($parameterType->getName());
|
|
/*
|
|
* We don't want to check integrity of @api coverage of classes
|
|
* that belong to different vendors, because it is too complicated.
|
|
* Example:
|
|
* If Magento class references non-@api annotated class from Zend,
|
|
* we don't want to fail test, because Zend is considered public by default,
|
|
* and we don't care if Zend classes are @api-annotated
|
|
*/
|
|
if ($parameterClass && !$parameterClass->isInternal()
|
|
&& $this->areClassesFromSameVendor($parameterClass->getName(), $class)
|
|
&& !$this->isPublished($parameterClass)
|
|
) {
|
|
$nonPublishedClasses[$parameterClass->getName()] = $parameterClass->getName();
|
|
}
|
|
}
|
|
}
|
|
return $nonPublishedClasses;
|
|
}
|
|
}
|