├── composer.json ├── extension.neon └── src └── Rules ├── BannedNodesErrorBuilder.php ├── BannedNodesRule.php └── BannedUseTestRule.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ekino/phpstan-banned-code", 3 | "description": "Detected banned code using PHPStan", 4 | "license": "MIT", 5 | "type": "phpstan-extension", 6 | "keywords": [ 7 | "PHPStan", 8 | "Code Quality", 9 | "static analysis" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Rémi Marseille", 14 | "email": "remi.marseille@ekino.com", 15 | "homepage": "https://www.ekino.com" 16 | } 17 | ], 18 | "homepage": "https://github.com/ekino/phpstan-banned-code", 19 | "require": { 20 | "php": "^8.1", 21 | "phpstan/phpstan": "^2.0" 22 | }, 23 | "require-dev": { 24 | "ergebnis/composer-normalize": "^2.6", 25 | "friendsofphp/php-cs-fixer": "^3.0", 26 | "nikic/php-parser": "^5.4", 27 | "phpstan/phpstan-phpunit": "^2.0", 28 | "phpunit/phpunit": "^10.5", 29 | "symfony/var-dumper": "^6.4" 30 | }, 31 | "minimum-stability": "dev", 32 | "prefer-stable": true, 33 | "autoload": { 34 | "psr-4": { 35 | "Ekino\\PHPStanBannedCode\\": "src" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Tests\\Ekino\\PHPStanBannedCode\\": "tests" 41 | } 42 | }, 43 | "config": { 44 | "allow-plugins": { 45 | "ergebnis/composer-normalize": true 46 | } 47 | }, 48 | "extra": { 49 | "branch-alias": { 50 | "dev-master": "1.0-dev" 51 | }, 52 | "phpstan": { 53 | "includes": [ 54 | "extension.neon" 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | parametersSchema: 2 | banned_code: structure([ 3 | nodes: listOf(structure([ 4 | type: string() 5 | functions: schema(listOf(string()), nullable()) 6 | ])) 7 | use_from_tests: bool() 8 | non_ignorable: bool() 9 | ]) 10 | 11 | parameters: 12 | banned_code: 13 | nodes: 14 | # enable detection of echo 15 | - 16 | type: Stmt_Echo 17 | functions: null 18 | 19 | # enable detection of eval 20 | - 21 | type: Expr_Eval 22 | functions: null 23 | 24 | # enable detection of die/exit 25 | - 26 | type: Expr_Exit 27 | functions: null 28 | 29 | # enable detection of a set of functions 30 | - 31 | type: Expr_FuncCall 32 | functions: 33 | - dd 34 | - debug_backtrace 35 | - dump 36 | - exec 37 | - passthru 38 | - phpinfo 39 | - print_r 40 | - proc_open 41 | - shell_exec 42 | - system 43 | - var_dump 44 | 45 | # enable detection of print statements 46 | - 47 | type: Expr_Print 48 | functions: null 49 | 50 | # enable detection of shell execution by backticks 51 | - 52 | type: Expr_ShellExec 53 | functions: null 54 | 55 | # enable detection of empty() 56 | - 57 | type: Expr_Empty 58 | functions: null 59 | 60 | # enable detection of `use Tests\Foo\Bar` in a non-test file 61 | use_from_tests: true 62 | 63 | # when true, errors cannot be excluded 64 | non_ignorable: true 65 | 66 | services: 67 | - 68 | class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule 69 | tags: 70 | - phpstan.rules.rule 71 | arguments: 72 | - '%banned_code.nodes%' 73 | 74 | - 75 | class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule 76 | tags: 77 | - phpstan.rules.rule 78 | arguments: 79 | - '%banned_code.use_from_tests%' 80 | 81 | - 82 | class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder 83 | arguments: 84 | - '%banned_code.non_ignorable%' 85 | -------------------------------------------------------------------------------- /src/Rules/BannedNodesErrorBuilder.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class BannedNodesErrorBuilder 23 | { 24 | public const ERROR_IDENTIFIER_PREFIX = 'ekinoBannedCode'; 25 | 26 | public function __construct(private readonly bool $nonIgnorable) 27 | { 28 | } 29 | 30 | public function buildError( 31 | string $errorMessage, 32 | string $errorSuffix 33 | ): IdentifierRuleError { 34 | $errBuilder = RuleErrorBuilder::message($errorMessage) 35 | ->identifier(\sprintf('%s.%s', self::ERROR_IDENTIFIER_PREFIX, $errorSuffix)); 36 | 37 | if ($this->nonIgnorable) { 38 | $errBuilder->nonIgnorable(); 39 | } 40 | 41 | return $errBuilder->build(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Rules/BannedNodesRule.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class BannedNodesRule implements Rule 26 | { 27 | /** 28 | * @var array 29 | */ 30 | private array $bannedNodes; 31 | 32 | /** 33 | * @var array 34 | */ 35 | private array $bannedFunctions; 36 | 37 | /** 38 | * @param array}> $bannedNodes 39 | */ 40 | public function __construct( 41 | array $bannedNodes, 42 | private readonly BannedNodesErrorBuilder $errorBuilder 43 | ) { 44 | $this->bannedNodes = array_column($bannedNodes, null, 'type'); 45 | $this->bannedFunctions = $this->normalizeFunctionNames($this->bannedNodes); 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function getNodeType(): string 52 | { 53 | return Node::class; 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function processNode(Node $node, Scope $scope): array 60 | { 61 | $type = $node->getType(); 62 | 63 | if (!\array_key_exists($type, $this->bannedNodes)) { 64 | return []; 65 | } 66 | 67 | if ($node instanceof FuncCall) { 68 | if (!$node->name instanceof Name) { 69 | return []; 70 | } 71 | 72 | $function = $node->name->toString(); 73 | 74 | if (\in_array($function, $this->bannedFunctions)) { 75 | return [$this->errorBuilder->buildError( 76 | \sprintf('Should not use function "%s", please change the code.', $function), 77 | 'function', 78 | )]; 79 | } 80 | 81 | return []; 82 | } 83 | 84 | return [$this->errorBuilder->buildError( 85 | \sprintf('Should not use node with type "%s", please change the code.', $type), 86 | 'expression', 87 | )]; 88 | } 89 | 90 | /** 91 | * Strip leading slashes from function names. 92 | * 93 | * php-parser makes the same normalization. 94 | * 95 | * @param array $bannedNodes 96 | * @return array 97 | */ 98 | protected function normalizeFunctionNames(array $bannedNodes): array 99 | { 100 | return array_map( 101 | static function (string $function): string { 102 | return ltrim($function, '\\'); 103 | }, 104 | $bannedNodes['Expr_FuncCall']['functions'] ?? [] 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Rules/BannedUseTestRule.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class BannedUseTestRule implements Rule 25 | { 26 | public function __construct( 27 | private readonly bool $enabled, 28 | private readonly BannedNodesErrorBuilder $errorBuilder 29 | ) 30 | { 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getNodeType(): string 37 | { 38 | return Use_::class; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function processNode(Node $node, Scope $scope): array 45 | { 46 | if (!$this->enabled) { 47 | return []; 48 | } 49 | 50 | if (!$namespace = $scope->getNamespace()) { 51 | return []; 52 | } 53 | 54 | if (preg_match('#^Tests#', $namespace)) { 55 | return []; 56 | } 57 | 58 | if (!$node instanceof Use_) { 59 | throw new \InvalidArgumentException(\sprintf('$node must be an instance of %s, %s given', Use_::class, \get_class($node))); 60 | } 61 | 62 | $errors = []; 63 | 64 | foreach ($node->uses as $use) { 65 | if (preg_match('#^Tests#', $use->name->toString())) { 66 | $errors[] = $this->errorBuilder->buildError( 67 | \sprintf('Should not use %s in the non-test file %s', $use->name->toString(), $scope->getFile()), 68 | 'test', 69 | ); 70 | } 71 | } 72 | 73 | return $errors; 74 | } 75 | } 76 | --------------------------------------------------------------------------------