From ee89fcf95a2e44d1094a6fe833798929e06b2ead Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 30 Jul 2026 11:45:42 +0200 Subject: [PATCH 1/2] PHPStan: Add rule for `WP_CLI::add_hook()` usage Complements #344 --- extension.neon | 3 + src/PHPStan/WPCliAddHookCallbackRule.php | 137 ++++++++++++++++++ tests/data/add_hook_rule.php | 54 +++++++ .../PHPStan/TestWPCliAddHookCallbackRule.php | 39 +++++ 4 files changed, 233 insertions(+) create mode 100644 src/PHPStan/WPCliAddHookCallbackRule.php create mode 100644 tests/data/add_hook_rule.php create mode 100644 tests/tests/PHPStan/TestWPCliAddHookCallbackRule.php diff --git a/extension.neon b/extension.neon index 44c094fd..6fbf5b1b 100644 --- a/extension.neon +++ b/extension.neon @@ -19,6 +19,9 @@ services: class: SzepeViktor\PHPStan\WordPress\HookDocsVisitor tags: - phpstan.parser.richParserNodeVisitor +rules: + - WP_CLI\Tests\PHPStan\WPCliAddHookCallbackRule + parameters: dynamicConstantNames: - FOO diff --git a/src/PHPStan/WPCliAddHookCallbackRule.php b/src/PHPStan/WPCliAddHookCallbackRule.php new file mode 100644 index 00000000..9ba527a5 --- /dev/null +++ b/src/PHPStan/WPCliAddHookCallbackRule.php @@ -0,0 +1,137 @@ + + */ +final class WPCliAddHookCallbackRule implements Rule { + + private const KNOWN_HOOK_ARGS = [ + 'find_command_to_run_pre' => 0, + 'before_wp_load' => 0, + 'before_wp_config_load' => 0, + 'after_wp_config_load' => 0, + 'after_wp_load' => 0, + 'before_ssh' => 0, + 'before_registering_contexts' => 1, + 'formatter_available_formats' => 1, + 'http_request_options' => 5, + 'before_run_command' => 3, + 'search_replace_unserialize_options' => 1, + ]; + + private const DYNAMIC_HOOK_PREFIXES = [ + 'before_add_command:' => 1, + 'after_add_command:' => 0, + 'before_invoke:' => 1, + 'after_invoke:' => 1, + ]; + + public function getNodeType(): string { + return StaticCall::class; + } + + public function processNode( Node $node, Scope $scope ): array { + if ( ! $node instanceof StaticCall ) { + return []; + } + + if ( ! $node->name instanceof Node\Identifier || 'add_hook' !== $node->name->name ) { + return []; + } + + if ( ! $node->class instanceof Node\Name || 'WP_CLI' !== $scope->resolveName( $node->class ) ) { + return []; + } + + $args = $node->getArgs(); + if ( count( $args ) < 2 ) { + return []; + } + + $callbackType = $scope->getType( $args[1]->value ); + if ( ! $callbackType->isCallable()->yes() ) { + if ( $callbackType->isCallable()->no() ) { + return [ + RuleErrorBuilder::message( + sprintf( + 'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, %s given.', + $callbackType->describe( VerbosityLevel::typeOnly() ) + ) + )->build(), + ]; + } + return []; + } + + $hookNameType = $scope->getType( $args[0]->value ); + $hookNameStrings = $hookNameType->getConstantStrings(); + if ( count( $hookNameStrings ) !== 1 ) { + return []; + } + + $hookName = $hookNameStrings[0]->getValue(); + $expectedArgs = $this->getExpectedArgCountForHook( $hookName ); + if ( null === $expectedArgs ) { + return []; + } + + $callableParametersAcceptors = $callbackType->getCallableParametersAcceptors( $scope ); + if ( count( $callableParametersAcceptors ) === 0 ) { + return []; + } + + $parametersAcceptor = $callableParametersAcceptors[0]; + $requiredParams = 0; + foreach ( $parametersAcceptor->getParameters() as $parameter ) { + if ( ! $parameter->isOptional() ) { + ++$requiredParams; + } + } + + if ( $requiredParams > $expectedArgs ) { + return [ + RuleErrorBuilder::message( + sprintf( + 'Callback for hook "%s" expects %d required %s, but only %d %s passed by WP_CLI::do_hook().', + $hookName, + $requiredParams, + 1 === $requiredParams ? 'argument' : 'arguments', + $expectedArgs, + 1 === $expectedArgs ? 'argument is' : 'arguments are' + ) + )->build(), + ]; + } + + return []; + } + + private function getExpectedArgCountForHook( string $hookName ): ?int { + if ( isset( self::KNOWN_HOOK_ARGS[ $hookName ] ) ) { + return self::KNOWN_HOOK_ARGS[ $hookName ]; + } + + foreach ( self::DYNAMIC_HOOK_PREFIXES as $prefix => $count ) { + if ( 0 === strpos( $hookName, $prefix ) ) { + return $count; + } + } + + return null; + } +} diff --git a/tests/data/add_hook_rule.php b/tests/data/add_hook_rule.php new file mode 100644 index 00000000..72066014 --- /dev/null +++ b/tests/data/add_hook_rule.php @@ -0,0 +1,54 @@ + + */ +class TestWPCliAddHookCallbackRule extends RuleTestCase { + + protected function getRule(): Rule { + return new WPCliAddHookCallbackRule(); + } + + public function testRule(): void { + $this->analyse( + [ __DIR__ . '/../../data/add_hook_rule.php' ], + [ + [ + 'Callback for hook "before_wp_load" expects 1 required argument, but only 0 arguments are passed by WP_CLI::do_hook().', + 30, + ], + [ + 'Callback for hook "before_invoke:user list" expects 2 required arguments, but only 1 argument is passed by WP_CLI::do_hook().', + 46, + ], + [ + 'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, string given.', + 54, + ], + ] + ); + } +} From 881bfc8bb1eab0d211624c7d7998f709b1c73751 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 30 Jul 2026 11:52:25 +0200 Subject: [PATCH 2/2] Add identifiers --- src/PHPStan/WPCliAddHookCallbackRule.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PHPStan/WPCliAddHookCallbackRule.php b/src/PHPStan/WPCliAddHookCallbackRule.php index 9ba527a5..fe636437 100644 --- a/src/PHPStan/WPCliAddHookCallbackRule.php +++ b/src/PHPStan/WPCliAddHookCallbackRule.php @@ -72,7 +72,7 @@ public function processNode( Node $node, Scope $scope ): array { 'Parameter #2 $callback of WP_CLI::add_hook() expects a valid callable, %s given.', $callbackType->describe( VerbosityLevel::typeOnly() ) ) - )->build(), + )->identifier( 'wpCli.addHookCallback.invalidCallback' )->build(), ]; } return []; @@ -114,7 +114,7 @@ public function processNode( Node $node, Scope $scope ): array { $expectedArgs, 1 === $expectedArgs ? 'argument is' : 'arguments are' ) - )->build(), + )->identifier( 'wpCli.addHookCallback.insufficientParameters' )->build(), ]; }