clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Classes;
use mglaman\PHPStanDrupal\Internal\NamespaceCheck;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function sprintf;
/**
* @implements Rule<Class_>
*/
class ClassExtendsInternalClassRule implements Rule
{
/**
* @var ReflectionProvider
*/
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!isset($node->extends)) {
return [];
}
$extendedClassName = $node->extends->toString();
if (!$this->reflectionProvider->hasClass($extendedClassName)) {
return [];
}
$extendedClassReflection = $this->reflectionProvider->getClass($extendedClassName);
if (!$extendedClassReflection->isInternal()) {
return [];
}
if (!isset($node->namespacedName)) {
return [$this->buildError(null, $extendedClassName)->build()];
}
$currentClassName = $node->namespacedName->toString();
if (!NamespaceCheck::isDrupalNamespace($node)) {
return [$this->buildError($currentClassName, $extendedClassName)->build()];
}
if (NamespaceCheck::isSharedNamespace($node)) {
return [];
}
$errorBuilder = $this->buildError($currentClassName, $extendedClassName);
if ($extendedClassName === 'Drupal\Core\Entity\ContentEntityDeleteForm') {
$errorBuilder->tip('Extend \Drupal\Core\Entity\ContentEntityConfirmFormBase. See https://www.drupal.org/node/2491057');
} elseif ((string) $node->extends->slice(0, 2) === 'Drupal\Core') {
$errorBuilder->tip('Read the Drupal core backwards compatibility and internal API policy: https://www.drupal.org/about/core/policies/core-change-policies/drupal-8-and-9-backwards-compatibility-and-internal-api#internal');
}
return [$errorBuilder->build()];
}
private function buildError(?string $currentClassName, string $extendedClassName): RuleErrorBuilder
{
return RuleErrorBuilder::message(sprintf(
'%s extends @internal class %s.',
$currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class',
$extendedClassName
));
}
}

View File

@@ -0,0 +1,141 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Classes;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Type\ObjectType;
use function sprintf;
/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Stmt\Class_>
*/
class PluginManagerInspectionRule implements Rule
{
/** @var ReflectionProvider */
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if ($node->namespacedName === null) {
// anonymous class
return [];
}
if ($node->extends === null) {
return [];
}
$className = (string) $node->namespacedName;
$pluginManagerType = new ObjectType($className);
$pluginManagerInterfaceType = new ObjectType('\Drupal\Component\Plugin\PluginManagerInterface');
if (!$pluginManagerInterfaceType->isSuperTypeOf($pluginManagerType)->yes()) {
return [];
}
$errors = [];
if ($this->isYamlDiscovery($node)) {
$errors = $this->inspectYamlPluginManager($node);
} else {
// @todo inspect annotated plugin managers.
}
$hasAlterInfoSet = false;
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === '__construct') {
foreach ($stmt->stmts ?? [] as $statement) {
if ($statement instanceof Node\Stmt\Expression) {
$statement = $statement->expr;
}
if ($statement instanceof Node\Expr\MethodCall
&& $statement->name instanceof Node\Identifier
&& $statement->name->name === 'alterInfo') {
$hasAlterInfoSet = true;
}
}
}
}
if (!$hasAlterInfoSet) {
$errors[] = 'Plugin definitions cannot be altered.';
}
return $errors;
}
private function isYamlDiscovery(Node\Stmt\Class_ $class): bool
{
foreach ($class->stmts as $stmt) {
// YAML discovery plugin managers must override getDiscovery.
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === 'getDiscovery') {
foreach ($stmt->stmts ?? [] as $methodStmt) {
if ($methodStmt instanceof Node\Stmt\If_) {
foreach ($methodStmt->stmts as $ifStmt) {
if ($ifStmt instanceof Node\Stmt\Expression) {
$ifStmtExpr = $ifStmt->expr;
if ($ifStmtExpr instanceof Node\Expr\Assign) {
$ifStmtExprVar = $ifStmtExpr->var;
if ($ifStmtExprVar instanceof Node\Expr\PropertyFetch
&& $ifStmtExprVar->var instanceof Node\Expr\Variable
&& $ifStmtExprVar->name instanceof Node\Identifier
&& $ifStmtExprVar->name->name === 'discovery'
) {
$ifStmtExprExpr = $ifStmtExpr->expr;
if ($ifStmtExprExpr instanceof Node\Expr\New_
&& ($ifStmtExprExpr->class instanceof Node\Name)
&& $ifStmtExprExpr->class->toString() === 'Drupal\Core\Plugin\Discovery\YamlDiscovery') {
return true;
}
}
}
}
}
}
}
}
}
return false;
}
private function inspectYamlPluginManager(Node\Stmt\Class_ $class): array
{
$errors = [];
$fqn = (string) $class->namespacedName;
$reflection = $this->reflectionProvider->getClass($fqn);
$constructor = $reflection->getConstructor();
if ($constructor->getDeclaringClass()->getName() !== $fqn) {
$errors[] = sprintf('%s must override __construct if using YAML plugins.', $fqn);
} else {
foreach ($class->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === '__construct') {
foreach ($stmt->stmts ?? [] as $constructorStmt) {
if ($constructorStmt instanceof Node\Stmt\Expression) {
$constructorStmt = $constructorStmt->expr;
}
if ($constructorStmt instanceof Node\Expr\StaticCall
&& $constructorStmt->class instanceof Node\Name
&& ((string)$constructorStmt->class === 'parent')
&& $constructorStmt->name instanceof Node\Identifier
&& $constructorStmt->name->name === '__construct') {
$errors[] = sprintf('YAML plugin managers should not invoke its parent constructor.');
}
}
}
}
}
return $errors;
}
}

View File

@@ -0,0 +1,132 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use function array_merge;
use function explode;
use function sprintf;
/**
* @implements Rule<Node\Expr\ConstFetch>
*/
class AccessDeprecatedConstant implements Rule
{
/** @var ReflectionProvider */
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Expr\ConstFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
// nikic/php-parser does not let us access phpdoc comments from deprecated constants, so
// here goes a list of hardcoded core constants. List is available at
// https://api.drupal.org/api/drupal/deprecated/8.9.x?order=object_type&sort=asc&page=5
$deprecatedConstants = [
'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.',
'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.',
'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.',
'DRUPAL_ANONYMOUS_RID' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Session\AccountInterface::ANONYMOUS_ROLE or \Drupal\user\RoleInterface::ANONYMOUS_ID instead.',
'DRUPAL_AUTHENTICATED_RID' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Session\AccountInterface::AUTHENTICATED_ROLE or \Drupal\user\RoleInterface::AUTHENTICATED_ID instead.',
'REQUEST_TIME' => 'Deprecated in drupal:8.3.0 and is removed from drupal:11.0.0. Use \Drupal::time()->getRequestTime(); ',
'DRUPAL_PHP_FUNCTION_PATTERN' => 'Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Extension\ExtensionDiscovery::PHP_FUNCTION_PATTERN instead.',
'CONFIG_ACTIVE_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Drupal core no longer creates an active directory.',
'CONFIG_SYNC_DIRECTORY' => 'Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Site\Settings::get(\'config_sync_directory\') instead.',
'CONFIG_STAGING_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. The staging directory was renamed to sync.',
'LOCALE_PLURAL_DELIMITER' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Component\Gettext\PoItem::DELIMITER instead.',
'FILE_CHMOD_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystem::CHMOD_DIRECTORY.',
'FILE_CHMOD_FILE' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystem::CHMOD_FILE.',
'FILE_CREATE_DIRECTORY' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::CREATE_DIRECTORY.',
'FILE_MODIFY_PERMISSIONS' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::MODIFY_PERMISSIONS.',
'FILE_EXISTS_RENAME' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_RENAME.',
'FILE_EXISTS_REPLACE' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_REPLACE.',
'FILE_EXISTS_ERROR' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_ERROR.',
'AGGREGATOR_CLEAR_NEVER' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\aggregator\FeedStorageInterface::CLEAR_NEVER instead.',
'COMMENT_ANONYMOUS_MAYNOT_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MAYNOT_CONTACT instead.',
'COMMENT_ANONYMOUS_MAY_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MAY_CONTACT instead.',
'COMMENT_ANONYMOUS_MUST_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MUST_CONTACT instead.',
'IMAGE_STORAGE_NORMAL' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_OVERRIDE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_DEFAULT' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_EDITABLE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_MODULE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'MENU_MAX_MENU_NAME_LENGTH_UI' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\system\MenuStorage::MAX_ID_LENGTH instead.',
'NODE_NOT_PUBLISHED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_PUBLISHED instead.',
'NODE_PUBLISHED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::PUBLISHED instead.',
'NODE_NOT_PROMOTED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_PROMOTED instead.',
'NODE_PROMOTED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::PROMOTED instead.',
'NODE_NOT_STICKY' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_STICKY instead.',
'NODE_STICKY' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::STICKY instead.',
'RESPONSIVE_IMAGE_EMPTY_IMAGE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use Drupal\responsive_image\ResponsiveImageStyleInterface::EMPTY_IMAGE instead.',
'RESPONSIVE_IMAGE_ORIGINAL_IMAGE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\responsive_image\ResponsiveImageStyleInterface::ORIGINAL_IMAGE instead.',
'DRUPAL_USER_TIMEZONE_DEFAULT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_DEFAULT instead.',
'DRUPAL_USER_TIMEZONE_EMPTY' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_EMPTY instead.',
'DRUPAL_USER_TIMEZONE_SELECT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_SELECT instead.',
'TAXONOMY_HIERARCHY_DISABLED' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_DISABLED instead.',
'TAXONOMY_HIERARCHY_SINGLE' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_SINGLE instead.',
'TAXONOMY_HIERARCHY_MULTIPLE' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_MULTIPLE instead.',
'UPDATE_NOT_SECURE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_SECURE instead.',
'UPDATE_REVOKED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::REVOKED instead.',
'UPDATE_NOT_SUPPORTED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_SUPPORTED instead.',
'UPDATE_NOT_CURRENT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_CURRENT instead.',
'UPDATE_CURRENT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::CURRENT instead.',
'UPDATE_NOT_CHECKED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::NOT_CHECKED instead.',
'UPDATE_UNKNOWN' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::UNKNOWN instead.',
'UPDATE_NOT_FETCHED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::NOT_FETCHED instead.',
'UPDATE_FETCH_PENDING' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::FETCH_PENDING instead.',
'USERNAME_MAX_LENGTH' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::USERNAME_MAX_LENGTH instead.',
'USER_REGISTER_ADMINISTRATORS_ONLY' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_ADMINISTRATORS_ONLY instead.',
'USER_REGISTER_VISITORS' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_VISITORS instead.',
'USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL instead.',
];
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major === '9') {
if ((int) $minor >= 1) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'DRUPAL_MINIMUM_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_PHP instead.',
'DRUPAL_MINIMUM_PHP_MEMORY_LIMIT' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_PHP_MEMORY_LIMIT instead.',
'DRUPAL_MINIMUM_SUPPORTED_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_SUPPORTED_PHP instead.',
'DRUPAL_RECOMMENDED_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::RECOMMENDED_PHP instead.',
'PREG_CLASS_CJK' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_CJK instead.',
'PREG_CLASS_NUMBERS' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_NUMBERS',
'PREG_CLASS_PUNCTUATION' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_PUNCTUATION',
]);
}
if ((int) $minor >= 2) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'FILE_INSECURE_EXTENSION_REGEX' => 'Deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Use \Drupal\Core\File\FileSystemInterface::INSECURE_EXTENSION_REGEX.',
]);
}
if ((int) $minor >= 3) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'FILE_STATUS_PERMANENT' => 'Deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\FileInterface::STATUS_PERMANENT or \Drupal\file\FileInterface::setPermanent().',
'SCHEMA_UNINSTALLED' => 'Deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\Core\Update\UpdateHookRegistry::SCHEMA_UNINSTALLED',
]);
}
}
$constantName = $this->reflectionProvider->resolveConstantName($node->name, $scope);
if (isset($deprecatedConstants[$constantName])) {
return [
sprintf('Call to deprecated constant %s: %s', $constantName, $deprecatedConstants[$constantName])
];
}
return [];
}
}

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal\Core\Condition\ConditionManager;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ObjectType;
use function count;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class ConditionManagerCreateInstanceContextConfigurationRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
if ($node->name->toString() !== 'createInstance') {
return [];
}
$args = $node->getArgs();
if (count($args) !== 2) {
return [];
}
$conditionManagerType = new ObjectType(ConditionManager::class);
$type = $scope->getType($node->var);
if (!$conditionManagerType->isSuperTypeOf($type)->yes()) {
return [];
}
$configuration = $args[1];
$configurationType = $scope->getType($configuration->value);
// Must be an array, return [] and allow parameter inspection rule to report error.
if (!$configurationType instanceof ConstantArrayType) {
return [];
}
foreach ($configurationType->getKeyTypes() as $keyType) {
if ($keyType instanceof ConstantStringType && $keyType->getValue() === 'context') {
return [
RuleErrorBuilder::message('Passing context values to plugins via configuration is deprecated in drupal:9.1.0 and will be removed before drupal:10.0.0. Instead, call ::setContextValue() on the plugin itself. See https://www.drupal.org/node/3120980')
->line($node->getStartLine())
->build()
];
}
}
return [];
}
}

View File

@@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\ShouldNotHappenException;
use function preg_match;
final class ConfigEntityConfigExportRule extends DeprecatedAnnotationsRuleBase
{
protected function getExpectedInterface(): string
{
return 'Drupal\Core\Config\Entity\ConfigEntityInterface';
}
protected function doProcessNode(ClassReflection $reflection, Node\Stmt\Class_ $node, Scope $scope): array
{
$phpDoc = $reflection->getResolvedPhpDoc();
// Plugins should always be annotated, but maybe this class is missing its
// annotation since it swaps an existing one.
if ($phpDoc === null || !$this->isAnnotated($phpDoc)) {
return [];
}
$hasMatch = preg_match('/config_export\s?=\s?{/', $phpDoc->getPhpDocString());
if ($hasMatch === false) {
throw new ShouldNotHappenException('Unexpected error when trying to run match on phpDoc string.');
}
if ($hasMatch === 0) {
return [
'Configuration entity must define a `config_export` key. See https://www.drupal.org/node/2481909',
];
}
return [];
}
private function isAnnotated(ResolvedPhpDocBlock $phpDoc): bool
{
foreach ($phpDoc->getPhpDocNodes() as $docNode) {
foreach ($docNode->children as $childNode) {
if (($childNode instanceof PhpDocTagNode) && $childNode->name === '@ConfigEntityType') {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Stmt\Class_>
*/
abstract class DeprecatedAnnotationsRuleBase implements Rule
{
/**
* @var \PHPStan\Reflection\ReflectionProvider
*/
protected $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
abstract protected function getExpectedInterface(): string;
abstract protected function doProcessNode(
ClassReflection $reflection,
Node\Stmt\Class_ $node,
Scope $scope
): array;
public function processNode(Node $node, Scope $scope): array
{
if ($node->extends === null) {
return [];
}
if ($node->name === null) {
return [];
}
if ($node->isAbstract()) {
return [];
}
// PHPStan gives anonymous classes a name, so we cannot determine if
// a class is truly anonymous using the normal methods from php-parser.
// @see \PHPStan\Reflection\BetterReflection\BetterReflectionProvider::getAnonymousClassReflection
if ($node->hasAttribute('anonymousClass') && $node->getAttribute('anonymousClass') === true) {
return [];
}
$className = $node->name->name;
$namespace = $scope->getNamespace();
$reflection = $this->reflectionProvider->getClass($namespace . '\\' . $className);
$implementsExpectedInterface = $reflection->implementsInterface($this->getExpectedInterface());
if (!$implementsExpectedInterface) {
return [];
}
return $this->doProcessNode($reflection, $node, $scope);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Function_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function basename;
use function explode;
use function strlen;
use function substr_replace;
/**
* @implements Rule<Function_>
*/
class DeprecatedHookImplementation implements Rule
{
protected ReflectionProvider $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Function_::class;
}
public function processNode(Node $node, Scope $scope) : array
{
if (!str_ends_with($scope->getFile(), ".module") && !str_ends_with($scope->getFile(), ".inc")) {
return [];
}
// We want both name.module and name.views.inc, to resolve to name.
$module_name = explode(".", basename($scope->getFile()))[0];
// Hooks start with their own module's name.
if (!str_starts_with($node->name->toString(), "{$module_name}_")) {
return [];
}
$function_name = $node->name->toString();
$hook_name = substr_replace($function_name, "hook", 0, strlen($module_name));
$hook_name_node = new Name($hook_name);
if (!$this->reflectionProvider->hasFunction($hook_name_node, $scope)) {
// @todo replace this hardcoded logic with something more intelligent and extensible.
if ($hook_name === 'hook_field_widget_form_alter') {
return $this->buildError(
$function_name,
$hook_name,
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_single_element_form_alter instead.'
);
}
if (str_starts_with($hook_name, 'hook_field_widget_') && str_ends_with($hook_name, '_form_alter')) {
return $this->buildError(
$function_name,
'hook_field_widget_WIDGET_TYPE_form_alter',
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_single_element_WIDGET_TYPE_form_alter instead.'
);
}
if ($hook_name === 'hook_field_widget_multivalue_form_alter') {
return $this->buildError(
$function_name,
$hook_name,
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_complete_form_alter instead.'
);
}
if (str_starts_with($hook_name, 'hook_field_widget_multivalue_') && str_ends_with($hook_name, '_form_alter')) {
return $this->buildError(
$function_name,
'hook_field_widget_multivalue_WIDGET_TYPE_form_alter',
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_complete_WIDGET_TYPE_form_alter instead.'
);
}
return [];
}
$reflection = $this->reflectionProvider->getFunction($hook_name_node, $scope);
if (!$reflection->isDeprecated()->yes()) {
return [];
}
return $this->buildError($function_name, $hook_name, $reflection->getDeprecatedDescription());
}
private function buildError(string $function_name, string $hook_name, ?string $deprecated_description): array
{
$deprecated_description = $deprecated_description !== null ? " $deprecated_description" : ".";
return [
RuleErrorBuilder::message(
"Function $function_name implements $hook_name which is deprecated$deprecated_description",
)->build()
];
}
}

View File

@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class GetDeprecatedServiceRule implements Rule
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'get') {
return [];
}
$methodReflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString());
if ($methodReflection === null) {
return [];
}
$declaringClass = $methodReflection->getDeclaringClass();
if ($declaringClass->getName() !== 'Symfony\Component\DependencyInjection\ContainerInterface') {
return [];
}
$serviceNameArg = $node->args[0];
assert($serviceNameArg instanceof Node\Arg);
$serviceName = $serviceNameArg->value;
// @todo check if var, otherwise throw.
// ACTUALLY what if it was a constant? can we use a resolver.
if (!$serviceName instanceof Node\Scalar\String_) {
return [];
}
$service = $this->serviceMap->getService($serviceName->value);
if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
return [$service->getDeprecatedDescription()];
}
return [];
}
}

View File

@@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\ShouldNotHappenException;
use function preg_match;
final class PluginAnnotationContextDefinitionsRule extends DeprecatedAnnotationsRuleBase
{
protected function getExpectedInterface(): string
{
return 'Drupal\Component\Plugin\ContextAwarePluginInterface';
}
protected function doProcessNode(ClassReflection $reflection, Node\Stmt\Class_ $node, Scope $scope): array
{
$annotation = $reflection->getResolvedPhpDoc();
// Plugins should always be annotated, but maybe this class is missing its
// annotation since it swaps an existing one.
if ($annotation === null) {
return [];
}
$hasMatch = preg_match('/context\s?=\s?{/', $annotation->getPhpDocString());
if ($hasMatch === false) {
throw new ShouldNotHappenException('Unexpected error when trying to run match on phpDoc string.');
}
if ($hasMatch === 1) {
return [
'Providing context definitions via the "context" key is deprecated in Drupal 8.7.x and will be removed before Drupal 9.0.0. Use the "context_definitions" key instead.',
];
}
return [];
}
}

View File

@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
final class StaticServiceDeprecatedServiceRule implements Rule
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'service') {
return [];
}
$class = $node->class;
if ($class instanceof Node\Name) {
$calledOnType = $scope->resolveTypeByName($class);
} else {
$calledOnType = $scope->getType($class);
}
$methodReflection = $scope->getMethodReflection($calledOnType, $node->name->toString());
if ($methodReflection === null) {
return [];
}
$declaringClass = $methodReflection->getDeclaringClass();
if ($declaringClass->getName() !== 'Drupal') {
return [];
}
$serviceNameArg = $node->args[0];
assert($serviceNameArg instanceof Node\Arg);
$serviceName = $serviceNameArg->value;
// @todo check if var, otherwise throw.
// ACTUALLY what if it was a constant? can we use a resolver.
if (!$serviceName instanceof Node\Scalar\String_) {
return [];
}
$service = $this->serviceMap->getService($serviceName->value);
if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
return [$service->getDeprecatedDescription()];
}
return [];
}
}

View File

@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use Drupal\Core\Routing\RouteObjectInterface;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Cmf\Component\Routing\RouteObjectInterface as SymfonyRouteObjectInterface;
use function sprintf;
/**
* @implements Rule<Node\Expr\ClassConstFetch>
*/
final class SymfonyCmfRouteObjectInterfaceConstantsRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\ClassConstFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
if (!$node->class instanceof Node\Name) {
return [];
}
$constantName = $node->name->name;
$className = $node->class;
$classType = $scope->resolveTypeByName($className);
if (!$classType->hasConstant($constantName)->yes()) {
return [];
}
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major !== '9') {
return [];
}
if ((int) $minor < 1) {
return [];
}
// @phpstan-ignore-next-line
$cmfRouteObjectInterfaceType = new ObjectType(SymfonyRouteObjectInterface::class);
if (!$classType->isSuperTypeOf($cmfRouteObjectInterfaceType)->yes()) {
return [];
}
$coreRouteObjectInterfaceType = new ObjectType(RouteObjectInterface::class);
if (!$coreRouteObjectInterfaceType->hasConstant($constantName)->yes()) {
return [
RuleErrorBuilder::message(
sprintf('The core dependency symfony-cmf/routing is deprecated and %s::%s is not supported.', $className, $constantName)
)->tip('Change record: https://www.drupal.org/node/3151009')->build(),
];
}
return [
RuleErrorBuilder::message(
sprintf('%s::%s is deprecated and removed in Drupal 10. Use \Drupal\Core\Routing\RouteObjectInterface::%2$s instead.', $className, $constantName)
)->tip('Change record: https://www.drupal.org/node/3151009')->build(),
];
}
}

View File

@@ -0,0 +1,132 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassMethodNode;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Cmf\Component\Routing\LazyRouteCollection;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Cmf\Component\Routing\RouteProviderInterface;
use function explode;
use function sprintf;
/**
* @implements Rule<InClassMethodNode>
*/
final class SymfonyCmfRoutingInClassMethodSignatureRule implements Rule
{
public function getNodeType(): string
{
return InClassMethodNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major !== '9' || (int) $minor < 1) {
return [];
}
$method = $node->getMethodReflection();
// @phpstan-ignore-next-line
$cmfRouteObjectInterfaceType = new ObjectType(RouteObjectInterface::class);
// @phpstan-ignore-next-line
$cmfRouteProviderInterfaceType = new ObjectType(RouteProviderInterface::class);
// @phpstan-ignore-next-line
$cmfLazyRouteCollectionType = new ObjectType(LazyRouteCollection::class);
$methodSignature = ParametersAcceptorSelector::selectFromArgs(
$scope,
[],
$method->getVariants()
);
$errors = [];
$errorMessage = 'Parameter $%s of method %s() uses deprecated %s and removed in Drupal 10. Use %s instead.';
foreach ($methodSignature->getParameters() as $parameter) {
foreach ($parameter->getType()->getReferencedClasses() as $referencedClass) {
$referencedClassType = new ObjectType($referencedClass);
if ($cmfRouteObjectInterfaceType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\RouteObjectInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfRouteProviderInterfaceType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\RouteProviderInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfLazyRouteCollectionType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\LazyRouteCollection'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
}
}
}
$errorMessage = 'Return type of method %s::%s() has typehint with deprecated %s and is removed in Drupal 10. Use %s instead.';
$returnClasses = $methodSignature->getReturnType()->getReferencedClasses();
foreach ($returnClasses as $returnClass) {
$returnType = new ObjectType($returnClass);
if ($cmfRouteObjectInterfaceType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\RouteObjectInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfRouteProviderInterfaceType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\RouteProviderInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfLazyRouteCollectionType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\LazyRouteCollection'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
}
}
return $errors;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Access\AccessResult;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\VerbosityLevel;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
final class AccessResultConditionRule implements Rule
{
/** @var bool */
private $treatPhpDocTypesAsCertain;
/**
* @param bool $treatPhpDocTypesAsCertain
*/
public function __construct($treatPhpDocTypesAsCertain)
{
$this->treatPhpDocTypesAsCertain = $treatPhpDocTypesAsCertain;
}
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$methodName = $node->name->toString();
if (!in_array($methodName, ['allowedIf', 'forbiddenIf'], true)) {
return [];
}
if (!$node->class instanceof Node\Name) {
return [];
}
$className = $scope->resolveName($node->class);
if ($className !== AccessResult::class) {
return [];
}
$args = $node->getArgs();
if (count($args) === 0) {
return [];
}
$condition = $args[0]->value;
if (!$condition instanceof Node\Expr\BinaryOp\Identical && !$condition instanceof Node\Expr\BinaryOp\NotIdentical) {
return [];
}
$conditionType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition) : $scope->getNativeType($condition);
$bool = $conditionType->toBoolean();
if ($bool->isTrue()->or($bool->isFalse())->yes()) {
$leftType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition->left) : $scope->getNativeType($condition->left);
$rightType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition->right) : $scope->getNativeType($condition->right);
return [
RuleErrorBuilder::message(sprintf(
'Strict comparison using %s between %s and %s will always evaluate to %s.',
$condition->getOperatorSigil(),
$leftType->describe(VerbosityLevel::value()),
$rightType->describe(VerbosityLevel::value()),
$bool->describe(VerbosityLevel::value()),
))->identifier(sprintf('%s.alwaysFalse', $condition instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical'))->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,63 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Coder;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function in_array;
use function sprintf;
use function strtolower;
/**
* Based on Drupal_Sniffs_Functions_DiscouragedFunctionsSniff.
*
* @implements Rule<FuncCall>
*/
class DiscouragedFunctionsRule implements Rule
{
public function getNodeType(): string
{
return FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!($node->name instanceof Node\Name)) {
return [];
}
$name = strtolower((string)$node->name);
$discouragedFunctions = [
// Devel module debugging functions.
'dargs',
'dcp',
'dd',
'dfb',
'dfbt',
'dpm',
'dpq',
'dpr',
'dprint_r',
'drupal_debug',
'dsm',
'dvm',
'dvr',
'kdevel_print_object',
'kpr',
'kprint_r',
'sdpm',
// Functions which are not available on all
// PHP builds.
'fnmatch',
// Functions which are a security risk.
'eval',
];
if (in_array($name, $discouragedFunctions, true)) {
return [sprintf('Calls to function %s should not exist.', $name)];
}
return [];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<ClassPropertyNode>
*/
final class DependencySerializationTraitPropertyRule implements Rule
{
public function getNodeType(): string
{
return ClassPropertyNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->getClassReflection()->hasTraitUse(DependencySerializationTrait::class)) {
return [];
}
$errors = [];
if ($node->isPrivate()) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'%s does not support private properties.',
DependencySerializationTrait::class
)
)->tip('See https://www.drupal.org/node/3110266')->build();
}
if ($node->isReadOnly()) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'Read-only properties are incompatible with %s.',
DependencySerializationTrait::class
)
)->tip('See https://www.drupal.org/node/3110266')->build();
}
return $errors;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\EntityQuery;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryExecuteWithoutAccessCheckCountType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryExecuteWithoutAccessCheckType;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class EntityQueryHasAccessCheckRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
$name = $node->name;
if (!$name instanceof Node\Identifier) {
return [];
}
if ($name->toString() !== 'execute') {
return [];
}
$type = $scope->getType($node);
if (!$type instanceof EntityQueryExecuteWithoutAccessCheckCountType && !$type instanceof EntityQueryExecuteWithoutAccessCheckType) {
return [];
}
$parent = $scope->getType($node->var);
if ($parent instanceof ConfigEntityQueryType) {
return [];
}
return [
RuleErrorBuilder::message(
'Relying on entity queries to check access by default is deprecated in drupal:9.2.0 and an error will be thrown from drupal:10.0.0. Call \Drupal\Core\Entity\Query\QueryInterface::accessCheck() with TRUE or FALSE to specify whether access should be checked.'
)->tip('See https://www.drupal.org/node/3201242')->build(),
];
}
}

View File

@@ -0,0 +1,78 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
class GlobalDrupalDependencyInjectionRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
// Only check static calls to \Drupal
if (!($node->class instanceof Node\Name\FullyQualified) || (string) $node->class !== 'Drupal') {
return [];
}
// Do not raise if called inside a trait.
if (!$scope->isInClass() || $scope->isInTrait()) {
return [];
}
$scopeClassReflection = $scope->getClassReflection();
// Enums cannot have dependency injection.
if ($scopeClassReflection->isEnum()) {
return [];
}
$allowed_list = [
// Ignore tests.
'PHPUnit\Framework\Test',
// Typed data objects cannot use dependency injection.
'Drupal\Core\TypedData\TypedDataInterface',
// Render elements cannot use dependency injection.
'Drupal\Core\Render\Element\ElementInterface',
'Drupal\Core\Render\Element\FormElementInterface',
'Drupal\config_translation\FormElement\ElementInterface',
// Entities don't use services for now
// @see https://www.drupal.org/project/drupal/issues/2913224
'Drupal\Core\Entity\EntityInterface',
// Stream wrappers are only registered as a service for their tags
// and cannot use dependency injection. Function calls like
// file_exists, stat, etc. will construct the class directly.
'Drupal\Core\StreamWrapper\StreamWrapperInterface',
// Ignore Nightwatch test setup classes.
'Drupal\TestSite\TestSetupInterface',
];
foreach ($allowed_list as $item) {
if ($scopeClassReflection->implementsInterface($item)) {
return [];
}
}
$scopeFunction = $scope->getFunction();
if ($scopeFunction === null) {
return [];
}
if (!$scopeFunction instanceof ExtendedMethodReflection) {
return [];
}
if ($scopeFunction->isStatic()) {
return [];
}
return [
'\Drupal calls should be avoided in classes, use dependency injection instead'
];
}
}

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use mglaman\PHPStanDrupal\Drupal\ExtensionMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function count;
/**
* @template TNodeType of Node
* @implements Rule<TNodeType>
*/
abstract class LoadIncludeBase implements Rule
{
/**
* @var \mglaman\PHPStanDrupal\Drupal\ExtensionMap
*/
protected $extensionMap;
public function __construct(ExtensionMap $extensionMap)
{
$this->extensionMap = $extensionMap;
}
private function getStringArgValue(Node\Expr $expr, Scope $scope): ?string
{
$type = $scope->getType($expr);
$stringTypes = $type->getConstantStrings();
if (count($stringTypes) > 0) {
return $stringTypes[0]->getValue();
}
return null;
}
protected function parseLoadIncludeArgs(Node\Arg $module, Node\Arg $type, ?Node\Arg $name, Scope $scope): array
{
$moduleName = $this->getStringArgValue($module->value, $scope);
if ($moduleName === null) {
return [false, false];
}
$fileType = $this->getStringArgValue($type->value, $scope);
if ($fileType === null) {
return [false, false];
}
$baseName = null;
if ($name !== null) {
$baseName = $this->getStringArgValue($name->value, $scope);
if ($baseName === null) {
return [false, false];
}
}
if ($baseName === null) {
$baseName = $moduleName;
}
return [$moduleName, "$baseName.$fileType"];
}
}

View File

@@ -0,0 +1,92 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Extension\ModuleHandlerInterface;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Throwable;
use function count;
use function is_file;
use function sprintf;
/**
* @extends LoadIncludeBase<Node\Expr\MethodCall>
*/
class LoadIncludes extends LoadIncludeBase
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'loadInclude') {
return [];
}
$args = $node->getArgs();
if (count($args) < 2) {
return [];
}
$type = $scope->getType($node->var);
$moduleHandlerInterfaceType = new ObjectType(ModuleHandlerInterface::class);
if (!$type->isSuperTypeOf($moduleHandlerInterfaceType)->yes()) {
return [];
}
try {
// Try to invoke it similarly as the module handler itself.
[$moduleName, $filename] = $this->parseLoadIncludeArgs($args[0], $args[1], $args[2] ?? null, $scope);
if (!$moduleName && !$filename) {
// Couldn't determine module- nor file-name, most probably
// because it's a variable. Nothing to load, bail now.
return [];
}
$module = $this->extensionMap->getModule($moduleName);
if ($module === null) {
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from %s::loadInclude because %s module is not found.',
$filename,
ModuleHandlerInterface::class,
$moduleName
))
->line($node->getStartLine())
->build()
];
}
$file = $module->getAbsolutePath() . DIRECTORY_SEPARATOR . $filename;
if (is_file($file)) {
require_once $file;
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from %s::loadInclude',
$module->getPath() . '/' . $filename,
ModuleHandlerInterface::class
))
->line($node->getStartLine())
->build()
];
} catch (Throwable $e) {
return [
RuleErrorBuilder::message(sprintf(
'A file could not be loaded from %s::loadInclude',
ModuleHandlerInterface::class
))
->line($node->getStartLine())
->build()
];
}
}
}

View File

@@ -0,0 +1,80 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use Throwable;
use function count;
use function is_file;
use function sprintf;
/**
* Handles module_load_include dynamic file loading.
*
* @note may become deprecated and removed in D10
* @see https://www.drupal.org/project/drupal/issues/697946
*
* @extends LoadIncludeBase<Node\Expr\FuncCall>
*/
class ModuleLoadInclude extends LoadIncludeBase
{
public function getNodeType(): string
{
return Node\Expr\FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Name) {
return [];
}
$name = (string) $node->name;
if ($name !== 'module_load_include') {
return [];
}
$args = $node->getArgs();
if (count($args) < 2) {
return [];
}
try {
// Try to invoke it similarly as the module handler itself.
[$moduleName, $filename] = $this->parseLoadIncludeArgs($args[1], $args[0], $args[2] ?? null, $scope);
$module = $this->extensionMap->getModule($moduleName);
if ($module === null) {
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from module_load_include because %s module is not found.',
$filename,
$moduleName
))
->line($node->getStartLine())
->build()
];
}
$file = $module->getAbsolutePath() . DIRECTORY_SEPARATOR . $filename;
if (is_file($file)) {
require_once $file;
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from module_load_include.',
$module->getPath() . '/' . $filename
))
->line($node->getStartLine())
->build()
];
} catch (Throwable $e) {
return [
RuleErrorBuilder::message('A file could not be loaded from module_load_include')
->line($node->getStartLine())
->build()
];
}
}
}

View File

@@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\PluginManager;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
/**
* @template TNodeType of \PhpParser\Node
* @implements Rule<TNodeType>
*/
abstract class AbstractPluginManagerRule implements Rule
{
protected function isPluginManager(ClassReflection $classReflection): bool
{
return
!$classReflection->isInterface() &&
!$classReflection->isAnonymous() &&
$classReflection->implementsInterface('Drupal\Component\Plugin\PluginManagerInterface');
}
}

View File

@@ -0,0 +1,99 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\PluginManager;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Type;
use function array_map;
use function count;
use function sprintf;
use function strpos;
/**
* @extends AbstractPluginManagerRule<ClassMethod>
*/
class PluginManagerSetsCacheBackendRule extends AbstractPluginManagerRule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$scope->isInClass()) {
throw new ShouldNotHappenException();
}
if ($scope->isInTrait()) {
return [];
}
if ($node->name->name !== '__construct') {
return [];
}
$scopeClassReflection = $scope->getClassReflection();
if (!$this->isPluginManager($scopeClassReflection)) {
return [];
}
$hasCacheBackendSet = false;
$misnamedCacheTagWarnings = [];
foreach ($node->stmts ?? [] as $statement) {
if ($statement instanceof Node\Stmt\Expression) {
$statement = $statement->expr;
}
if (($statement instanceof Node\Expr\MethodCall) &&
($statement->name instanceof Node\Identifier) &&
$statement->name->name === 'setCacheBackend') {
// setCacheBackend accepts a cache backend, the cache key, and optional (but suggested) cache tags.
$setCacheBackendArgs = $statement->getArgs();
if (count($setCacheBackendArgs) < 2) {
continue;
}
$hasCacheBackendSet = true;
$cacheKey = array_map(
static fn (Type $type) => $type->getValue(),
$scope->getType($setCacheBackendArgs[1]->value)->getConstantStrings()
);
if (count($cacheKey) === 0) {
continue;
}
if (isset($setCacheBackendArgs[2])) {
$cacheTagsType = $scope->getType($setCacheBackendArgs[2]->value);
foreach ($cacheTagsType->getConstantArrays() as $constantArray) {
foreach ($constantArray->getValueTypes() as $valueType) {
foreach ($valueType->getConstantStrings() as $cacheTagConstantString) {
foreach ($cacheKey as $cacheKeyValue) {
if (strpos($cacheTagConstantString->getValue(), $cacheKeyValue) === false) {
$misnamedCacheTagWarnings[] = $cacheTagConstantString->getValue();
}
}
}
}
}
}
break;
}
}
$errors = [];
if (!$hasCacheBackendSet) {
$errors[] = 'Missing cache backend declaration for performance.';
}
foreach ($misnamedCacheTagWarnings as $cacheTagWarning) {
$errors[] = sprintf('%s cache tag might be unclear and does not contain the cache key in it.', $cacheTagWarning);
}
return $errors;
}
}

View File

@@ -0,0 +1,317 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Render\Element\RenderCallbackInterface;
use Drupal\Core\Render\PlaceholderGenerator;
use Drupal\Core\Render\Renderer;
use Drupal\Core\Security\Attribute\TrustedCallback;
use Drupal\Core\Security\TrustedCallbackInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StaticType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function array_map;
use function array_merge;
use function class_exists;
use function count;
use function explode;
use function preg_match;
use function sprintf;
use function substr_count;
/**
* @implements Rule<Node\Expr\ArrayItem>
*/
final class RenderCallbackRule implements Rule
{
private ReflectionProvider $reflectionProvider;
private ServiceMap $serviceMap;
private array $supportedKeys = [
'#pre_render',
'#post_render',
'#access_callback',
'#lazy_builder',
'#date_time_callbacks',
'#date_date_callbacks',
];
public function __construct(ReflectionProvider $reflectionProvider, ServiceMap $serviceMap)
{
$this->reflectionProvider = $reflectionProvider;
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\ArrayItem::class;
}
public function processNode(Node $node, Scope $scope): array
{
$key = $node->key;
if (!$key instanceof Node\Scalar\String_) {
return [];
}
// @see https://www.drupal.org/node/2966725
$keySearch = array_search($key->value, $this->supportedKeys, true);
if ($keySearch === false) {
return [];
}
$keyChecked = $this->supportedKeys[$keySearch];
$value = $node->value;
if ($keyChecked === '#access_callback') {
return $this->doProcessNode($node->value, $scope, $keyChecked, 0);
}
if ($keyChecked === '#lazy_builder') {
if ($scope->isInClass()) {
$classReflection = $scope->getClassReflection();
$classType = new ObjectType($classReflection->getName());
// These classes use #lazy_builder in array_intersect_key. With
// PHPStan 1.6, nodes do not track their parent/next/prev which
// saves a lot of memory. But makes it harder to detect if we're
// in a call to array_intersect_key. This is an easier workaround.
$allowedTypes = new UnionType([
new ObjectType(PlaceholderGenerator::class),
new ObjectType(Renderer::class),
new ObjectType('Drupal\Tests\Core\Render\RendererPlaceholdersTest'),
]);
if ($allowedTypes->isSuperTypeOf($classType)->yes()) {
return [];
}
}
if (!$value instanceof Node\Expr\Array_) {
return [
RuleErrorBuilder::message(sprintf('The "%s" expects a callable array with arguments.', $keyChecked))
->line($node->getStartLine())->build()
];
}
if (count($value->items) === 0) {
return [];
}
// @todo take $value->items[1] and validate parameters against the callback.
return $this->doProcessNode($value->items[0]->value, $scope, $keyChecked, 0);
}
if (!$value instanceof Node\Expr\Array_) {
return [
RuleErrorBuilder::message(sprintf('The "%s" render array value expects an array of callbacks.', $keyChecked))
->line($node->getStartLine())->build()
];
}
if (count($value->items) === 0) {
return [];
}
$errors = [];
foreach ($value->items as $pos => $item) {
$errors[] = $this->doProcessNode($item->value, $scope, $keyChecked, $pos);
}
return array_merge(...$errors);
}
/**
@return (string|\PHPStan\Rules\RuleError)[] errors
*/
private function doProcessNode(Node\Expr $node, Scope $scope, string $keyChecked, int $pos): array
{
$checkIsCallable = true;
$trustedCallbackType = new UnionType([
new ObjectType(TrustedCallbackInterface::class),
new ObjectType(RenderCallbackInterface::class),
]);
$errors = [];
$errorLine = $node->getStartLine();
$type = $this->getType($node, $scope);
foreach ($type->getConstantStrings() as $constantStringType) {
if (!$constantStringType->isCallable()->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
} elseif ($this->reflectionProvider->hasFunction(new Name($constantStringType->getValue()), null)) {
// We can determine if the callback is callable through the type system. However, we cannot determine
// if it is just a function or a static class call (MyClass::staticFunc).
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not trusted.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)
->tip('Change record: https://www.drupal.org/node/2966725.')
->build();
} else {
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $constantStringType->getValue(), $matches);
if (count($matches) === 0) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
} elseif (!$trustedCallbackType->isSuperTypeOf(new ObjectType($matches[1]))->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback class %s at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
}
}
}
foreach ($type->getConstantArrays() as $constantArrayType) {
if (!$constantArrayType->isCallable()->yes()) {
// If the right-hand side of the array is a variable, we cannot
// determine if it is callable. Bail now.
$itemType = $constantArrayType->getItemType();
if ($itemType instanceof UnionType) {
$unionConstantStrings = array_merge(...array_map(static function (Type $type) {
return $type->getConstantStrings();
}, $itemType->getTypes()));
if (count($unionConstantStrings) === 0) {
// Right-hand side of UnionType is not a constant string. We cannot determine if the dynamic
// value is callable or not.
$checkIsCallable = false;
break;
}
}
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantArrayType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
continue;
}
$typeAndMethodNames = $constantArrayType->findTypeAndMethodNames();
if ($typeAndMethodNames === []) {
continue;
}
foreach ($typeAndMethodNames as $typeAndMethodName) {
$isTrustedCallbackAttribute = TrinaryLogic::createNo()->lazyOr(
$typeAndMethodName->getType()->getObjectClassReflections(),
function (ClassReflection $reflection) use ($typeAndMethodName) {
if (!class_exists(TrustedCallback::class)) {
return TrinaryLogic::createNo();
}
$hasAttribute = $reflection->getNativeReflection()
->getMethod($typeAndMethodName->getMethod())
->getAttributes(TrustedCallback::class);
return TrinaryLogic::createFromBoolean(count($hasAttribute) > 0);
}
);
$isTrustedCallbackInterfaceType = $trustedCallbackType->isSuperTypeOf($typeAndMethodName->getType())->yes();
if (!$isTrustedCallbackInterfaceType && !$isTrustedCallbackAttribute->yes()) {
if (class_exists(TrustedCallback::class)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
"%s callback method '%s' at key '%s' does not implement attribute \Drupal\Core\Security\Attribute\TrustedCallback.",
$keyChecked,
$constantArrayType->describe(VerbosityLevel::value()),
$pos
)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/3349470')->build();
} else {
$errors[] = RuleErrorBuilder::message(
sprintf(
"%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.",
$keyChecked,
$typeAndMethodName->getType()->describe(VerbosityLevel::value()),
$pos
)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
}
}
}
}
// @todo move to its own rule for 1.2.0, FormClosureSerializationRule.
if (($type instanceof ClosureType) && $scope->isInClass()) {
$classReflection = $scope->getClassReflection();
$classType = new ObjectType($classReflection->getName());
$formType = new ObjectType('\Drupal\Core\Form\FormInterface');
if ($formType->isSuperTypeOf($classType)->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s may not contain a closure at key '%s' as forms may be serialized and serialization of closures is not allowed.", $keyChecked, $pos)
)->line($errorLine)->build();
}
}
if (count($errors) === 0 && ($checkIsCallable && !$type->isCallable()->yes())) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
}
return $errors;
}
// @todo move to a helper, as Drupal uses `service:method` references a lot.
private function getType(Node\Expr $node, Scope $scope): Type
{
$type = $scope->getType($node);
if ($type instanceof IntersectionType) {
// Covers concatenation of static::class . '::methodName'.
if ($node instanceof Node\Expr\BinaryOp\Concat) {
$leftType = $scope->getType($node->left);
$rightType = $scope->getType($node->right);
if ($rightType instanceof ConstantStringType && $leftType instanceof GenericClassStringType && $leftType->getGenericType() instanceof StaticType) {
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
$leftType->getGenericType(),
new ConstantStringType(ltrim($rightType->getValue(), ':'))
]
);
}
}
} elseif ($type instanceof ConstantStringType) {
if ($type->isClassStringType()->yes()) {
return $type;
}
// Covers \Drupal\Core\Controller\ControllerResolver::createController.
if (substr_count($type->getValue(), ':') === 1) {
[$class_or_service, $method] = explode(':', $type->getValue(), 2);
$serviceDefinition = $this->serviceMap->getService($class_or_service);
if ($serviceDefinition === null || $serviceDefinition->getClass() === null) {
return $type;
}
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new ObjectType($serviceDefinition->getClass()),
new ConstantStringType($method)
]
);
}
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $type->getValue(), $matches);
if (count($matches) > 0) {
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new StaticType($this->reflectionProvider->getClass($matches[1])),
new ConstantStringType($matches[2])
]
);
}
}
return $type;
}
}

View File

@@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Component\HttpFoundation\RequestStack as SymfonyRequestStack;
use function explode;
use function sprintf;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class RequestStackGetMainRequestRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
// Only valid for 9.3 -> 9.5. Deprecated in Drupal 10.
if (($major !== '9' || (int) $minor < 3)) {
return [];
}
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'getMasterRequest') {
return [];
}
$type = $scope->getType($node->var);
$symfonyRequestStackType = new ObjectType(SymfonyRequestStack::class);
if ($symfonyRequestStackType->isSuperTypeOf($type)->yes()) {
$message = sprintf(
'%s::getMasterRequest() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0 for Symfony 6 compatibility. Use the forward compatibility shim class %s and its getMainRequest() method instead.',
SymfonyRequestStack::class,
'Drupal\Core\Http\RequestStack'
);
return [
RuleErrorBuilder::message($message)
->tip('Change record: https://www.drupal.org/node/3253744')
->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPUnit\Framework\TestCase;
use function in_array;
use function sprintf;
/**
* @implements Rule<ClassPropertyNode>
*/
class TestClassesProtectedPropertyModulesRule implements Rule
{
public function getNodeType(): string
{
return ClassPropertyNode::class;
}
/**
* @throws \PHPStan\ShouldNotHappenException
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->getName() !== 'modules') {
return [];
}
$scopeClassReflection = $node->getClassReflection();
if ($scopeClassReflection->isAnonymous()) {
return [];
}
if (!in_array(TestCase::class, $scopeClassReflection->getParentClassesNames(), true)) {
return [];
}
if ($node->isPublic()) {
return [
RuleErrorBuilder::message(
sprintf('Property %s::$modules property must be protected.', $scopeClassReflection->getDisplayName())
)->tip('Change record: https://www.drupal.org/node/2909426')->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,112 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Tests;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\TypeCombinator;
use PHPUnit\Framework\Test;
use function count;
use function in_array;
use function interface_exists;
use function method_exists;
use function substr_compare;
/**
* @implements Rule<Node\Stmt\Class_>
*/
final class BrowserTestBaseDefaultThemeRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!interface_exists(Test::class)) {
return [];
}
if ($node->extends === null) {
return [];
}
if ($node->namespacedName === null) {
return [];
}
// Only inspect tests.
// @todo replace this str_ends_with() when php 8 is required.
if (0 !== substr_compare($node->namespacedName->getLast(), 'Test', -4)) {
return [];
}
// Do some cheap preflight tests to make sure the class is in a
// namespace that makes sense to inspect.
// @phpstan-ignore-next-line
$parts = method_exists($node->namespacedName, 'getParts') ? $node->namespacedName->getParts() : $node->namespacedName->parts;
// The namespace is too short to be a test so skip inspection.
if (count($parts) < 3) {
return [];
}
// If the 4th component matches it's a module test. If the 2nd, core.
if ($parts[3] !== 'Functional'
&& $parts [3] !== 'FunctionalJavascript'
&& $parts[1] !== 'FunctionalTests'
&& $parts[1] !== 'FunctionalJavascriptTests') {
return [];
}
$classType = $scope->resolveTypeByName($node->namespacedName);
assert($classType instanceof ObjectType);
$browserTestBaseType = new ObjectType('Drupal\\Tests\\BrowserTestBase');
if (!$browserTestBaseType->isSuperTypeOf($classType)->yes()) {
return [];
}
$excludedTestTypes = TypeCombinator::union(
new ObjectType('Drupal\\FunctionalTests\\Update\\UpdatePathTestBase'),
new ObjectType('Drupal\\FunctionalTests\\Installer\\InstallerConfigDirectoryTestBase'),
new ObjectType('Drupal\\FunctionalTests\\Installer\\InstallerExistingConfigTestBase')
);
if ($excludedTestTypes->isSuperTypeOf($classType)->yes()) {
return [];
}
$reflection = $classType->getClassReflection();
assert($reflection !== null);
if ($reflection->isAbstract()) {
return [];
}
$defaultProperties = $reflection->getNativeReflection()->getDefaultProperties();
$profile = $defaultProperties['profile'] ?? null;
$testingProfilesWithoutThemes = [
'testing',
'nightwatch_testing',
'testing_config_overrides',
'testing_missing_dependencies',
'testing_multilingual',
'testing_multilingual_with_english',
'testing_requirements',
];
if ($profile !== null && !in_array($profile, $testingProfilesWithoutThemes, true)) {
return [];
}
$defaultTheme = $defaultProperties['defaultTheme'] ?? null;
if ($defaultTheme === null || $defaultTheme === '') {
return [
RuleErrorBuilder::message('Drupal\Tests\BrowserTestBase::$defaultTheme is required. See https://www.drupal.org/node/3083055, which includes recommendations on which theme to use.')
->line($node->getStartLine())->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Tests;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPUnit\Framework\TestCase;
/**
* Implements rule that all non-abstract test classes name should end with "Test".
*
* @implements Rule<Node\Stmt\Class_>
*/
final class TestClassSuffixNameRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
// We're not interested in non-extending classes.
if ($node->extends === null) {
return [];
}
// We're not interested in abstract classes.
if ($node->isAbstract()) {
return [];
}
// We need a namespaced class name.
if ($node->namespacedName === null) {
return [];
}
// We're only interested in \PHPUnit\Framework\TestCase subtype classes.
$classType = $scope->resolveTypeByName($node->namespacedName);
$phpUnitFrameworkTestCaseType = new ObjectType(TestCase::class);
if (!$phpUnitFrameworkTestCaseType->isSuperTypeOf($classType)->yes()) {
return [];
}
// Check class name has suffix "Test".
// @todo replace this str_ends_with() when php 8 is required.
if (substr_compare($node->namespacedName->getLast(), 'Test', -4) === 0) {
return [];
}
return [
RuleErrorBuilder::message(
sprintf(
'Non-abstract test classes names should always have the suffix "Test", found incorrect class name "%s".',
$node->name,
)
)
->line($node->getStartLine())
->tip('See https://www.drupal.org/docs/develop/standards/php/object-oriented-code#naming')
->build()
];
}
}