From 912f3aab2ad9b23853eec7df22abe7d07717da87 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 30 Jul 2026 15:19:04 +0200 Subject: [PATCH 1/3] PHPStan: Add dynamic return type extension for `WP_CLI::get_config()` --- extension.neon | 4 + ...CliGetConfigDynamicReturnTypeExtension.php | 138 ++++++++++++++++++ tests/data/get_config.php | 27 ++++ .../TestDynamicReturnTypeExtension.php | 1 + 4 files changed, 170 insertions(+) create mode 100644 src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php create mode 100644 tests/data/get_config.php diff --git a/extension.neon b/extension.neon index 6fbf5b1b..10b46abc 100644 --- a/extension.neon +++ b/extension.neon @@ -15,6 +15,10 @@ services: class: WP_CLI\Tests\PHPStan\WPCliDoHookDynamicReturnTypeExtension tags: - phpstan.broker.dynamicStaticMethodReturnTypeExtension + - + class: WP_CLI\Tests\PHPStan\WPCliGetConfigDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension - class: SzepeViktor\PHPStan\WordPress\HookDocsVisitor tags: diff --git a/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php b/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php new file mode 100644 index 00000000..89164034 --- /dev/null +++ b/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php @@ -0,0 +1,138 @@ +getName() === 'get_config'; + } + + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope + ): Type { + $args = $methodCall->getArgs(); + + if ( count( $args ) === 0 ) { + return $this->getGlobalConfigArrayType(); + } + + $keyType = $scope->getType( $args[0]->value ); + + if ( $keyType->isNull()->yes() ) { + return $this->getGlobalConfigArrayType(); + } + + $constantStrings = $keyType->getConstantStrings(); + if ( count( $constantStrings ) > 0 ) { + $types = []; + $configMap = $this->getConfigMap(); + + foreach ( $constantStrings as $constantString ) { + $key = $constantString->getValue(); + if ( isset( $configMap[ $key ] ) ) { + $types[] = $configMap[ $key ]; + } else { + $types[] = new NullType(); + } + } + + if ( count( $types ) > 0 ) { + $returnType = TypeCombinator::union( ...$types ); + if ( $keyType->isNull()->maybe() ) { + $returnType = TypeCombinator::union( $returnType, $this->getGlobalConfigArrayType() ); + } + return $returnType; + } + } + + // Fallback for non-constant string or unknown types + $fallback = TypeCombinator::addNull( $this->getFallbackValueType() ); + if ( $keyType->isNull()->maybe() ) { + return TypeCombinator::union( $fallback, $this->getGlobalConfigArrayType() ); + } + + return $fallback; + } + + /** + * @return array + */ + private function getConfigMap(): array { + $stringType = new StringType(); + $stringOrNull = TypeCombinator::addNull( $stringType ); + $stringList = new ArrayType( new IntegerType(), $stringType ); + $boolType = new BooleanType(); + $trueOrStringList = TypeCombinator::union( new ConstantBooleanType( true ), $stringList ); + $stringOrTrue = TypeCombinator::union( $stringType, new ConstantBooleanType( true ) ); + $stringOrFalse = TypeCombinator::union( $stringType, new ConstantBooleanType( false ) ); + + return [ + 'path' => $stringOrNull, + 'ssh' => $stringOrNull, + 'ssh-args' => $stringList, + 'http' => $stringOrNull, + 'url' => $stringOrNull, + 'user' => $stringOrNull, + 'skip-plugins' => $trueOrStringList, + 'skip-themes' => $trueOrStringList, + 'skip-packages' => $boolType, + 'require' => $stringList, + 'exec' => $stringList, + 'context' => $stringType, + 'debug' => TypeCombinator::union( $stringType, $boolType ), + 'prompt' => TypeCombinator::union( $stringType, $boolType ), + 'quiet' => $boolType, + 'apache_modules' => $stringList, + 'assume-https' => $boolType, + 'color' => TypeCombinator::union( $stringType, $boolType ), + 'disabled_commands' => $stringList, + 'locale' => $stringType, + 'allow-root' => $boolType, + 'alias' => $stringType, + ]; + } + + private function getGlobalConfigArrayType(): Type { + $keyTypes = []; + $valueTypes = []; + + foreach ( $this->getConfigMap() as $key => $type ) { + $keyTypes[] = new ConstantStringType( $key ); + $valueTypes[] = $type; + } + + return new ConstantArrayType( $keyTypes, $valueTypes ); + } + + private function getFallbackValueType(): Type { + $types = array_values( $this->getConfigMap() ); + return TypeCombinator::union( ...$types ); + } +} diff --git a/tests/data/get_config.php b/tests/data/get_config.php new file mode 100644 index 00000000..5e41e9bc --- /dev/null +++ b/tests/data/get_config.php @@ -0,0 +1,27 @@ +, http: string|null, url: string|null, user: string|null, skip-plugins: array|true, skip-themes: array|true, skip-packages: bool, require: array, exec: array, context: string, debug: bool|string, prompt: bool|string, quiet: bool, apache_modules: array, assume-https: bool, color: bool|string, disabled_commands: array, locale: string, allow-root: bool, alias: string}', WP_CLI::get_config() ); + +// Specific keys +assertType( 'string|null', WP_CLI::get_config( 'path' ) ); +assertType( 'array', WP_CLI::get_config( 'ssh-args' ) ); +assertType( 'bool', WP_CLI::get_config( 'skip-packages' ) ); +assertType( 'array|true', WP_CLI::get_config( 'skip-plugins' ) ); +assertType( 'bool|string', WP_CLI::get_config( 'prompt' ) ); +assertType( 'bool', WP_CLI::get_config( 'quiet' ) ); +assertType( 'bool', WP_CLI::get_config( 'assume-https' ) ); + +// Invalid key +assertType( 'null', WP_CLI::get_config( 'invalid_key' ) ); diff --git a/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php b/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php index 5c4e7467..88d907ac 100644 --- a/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php +++ b/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php @@ -17,6 +17,7 @@ public static function dataFileAsserts(): iterable { yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/get_flag_value.php' ); yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/runcommand.php' ); yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/do_hook.php' ); + yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/get_config.php' ); } /** From bf03a2d2af9bfc7dc8383928584e1300dfb58625 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 30 Jul 2026 16:35:01 +0200 Subject: [PATCH 2/3] PHPCS fix --- ...CliGetConfigDynamicReturnTypeExtension.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php b/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php index 89164034..3ac8920c 100644 --- a/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php +++ b/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php @@ -94,18 +94,18 @@ private function getConfigMap(): array { $stringOrFalse = TypeCombinator::union( $stringType, new ConstantBooleanType( false ) ); return [ - 'path' => $stringOrNull, - 'ssh' => $stringOrNull, - 'ssh-args' => $stringList, - 'http' => $stringOrNull, - 'url' => $stringOrNull, - 'user' => $stringOrNull, - 'skip-plugins' => $trueOrStringList, - 'skip-themes' => $trueOrStringList, - 'skip-packages' => $boolType, - 'require' => $stringList, - 'exec' => $stringList, - 'context' => $stringType, + 'path' => $stringOrNull, + 'ssh' => $stringOrNull, + 'ssh-args' => $stringList, + 'http' => $stringOrNull, + 'url' => $stringOrNull, + 'user' => $stringOrNull, + 'skip-plugins' => $trueOrStringList, + 'skip-themes' => $trueOrStringList, + 'skip-packages' => $boolType, + 'require' => $stringList, + 'exec' => $stringList, + 'context' => $stringType, 'debug' => TypeCombinator::union( $stringType, $boolType ), 'prompt' => TypeCombinator::union( $stringType, $boolType ), 'quiet' => $boolType, From 626cd2bbf8de71139dd843938ca4a0911c347f22 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 30 Jul 2026 16:46:28 +0200 Subject: [PATCH 3/3] Address code review comments --- .../WPCliGetConfigDynamicReturnTypeExtension.php | 4 ++-- tests/data/get_config.php | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php b/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php index 3ac8920c..ff3e1bba 100644 --- a/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php +++ b/src/PHPStan/WPCliGetConfigDynamicReturnTypeExtension.php @@ -106,8 +106,8 @@ private function getConfigMap(): array { 'require' => $stringList, 'exec' => $stringList, 'context' => $stringType, - 'debug' => TypeCombinator::union( $stringType, $boolType ), - 'prompt' => TypeCombinator::union( $stringType, $boolType ), + 'debug' => $stringOrTrue, + 'prompt' => $stringOrFalse, 'quiet' => $boolType, 'apache_modules' => $stringList, 'assume-https' => $boolType, diff --git a/tests/data/get_config.php b/tests/data/get_config.php index 5e41e9bc..3426fea1 100644 --- a/tests/data/get_config.php +++ b/tests/data/get_config.php @@ -12,16 +12,26 @@ use function PHPStan\Testing\assertType; // No arguments -assertType( 'array{path: string|null, ssh: string|null, ssh-args: array, http: string|null, url: string|null, user: string|null, skip-plugins: array|true, skip-themes: array|true, skip-packages: bool, require: array, exec: array, context: string, debug: bool|string, prompt: bool|string, quiet: bool, apache_modules: array, assume-https: bool, color: bool|string, disabled_commands: array, locale: string, allow-root: bool, alias: string}', WP_CLI::get_config() ); +assertType( 'array{path: string|null, ssh: string|null, ssh-args: array, http: string|null, url: string|null, user: string|null, skip-plugins: array|true, skip-themes: array|true, skip-packages: bool, require: array, exec: array, context: string, debug: string|true, prompt: string|false, quiet: bool, apache_modules: array, assume-https: bool, color: bool|string, disabled_commands: array, locale: string, allow-root: bool, alias: string}', WP_CLI::get_config() ); +assertType( 'array{path: string|null, ssh: string|null, ssh-args: array, http: string|null, url: string|null, user: string|null, skip-plugins: array|true, skip-themes: array|true, skip-packages: bool, require: array, exec: array, context: string, debug: string|true, prompt: string|false, quiet: bool, apache_modules: array, assume-https: bool, color: bool|string, disabled_commands: array, locale: string, allow-root: bool, alias: string}', WP_CLI::get_config( null ) ); // Specific keys assertType( 'string|null', WP_CLI::get_config( 'path' ) ); assertType( 'array', WP_CLI::get_config( 'ssh-args' ) ); assertType( 'bool', WP_CLI::get_config( 'skip-packages' ) ); assertType( 'array|true', WP_CLI::get_config( 'skip-plugins' ) ); -assertType( 'bool|string', WP_CLI::get_config( 'prompt' ) ); +assertType( 'string|false', WP_CLI::get_config( 'prompt' ) ); assertType( 'bool', WP_CLI::get_config( 'quiet' ) ); assertType( 'bool', WP_CLI::get_config( 'assume-https' ) ); +// Nullable and non-constant keys +/** @var string|null $nullable_key */ +$nullable_key = null; +assertType( 'array<\'alias\'|\'allow-root\'|\'apache_modules\'|\'assume-https\'|\'color\'|\'context\'|\'debug\'|\'disabled_commands\'|\'exec\'|\'http\'|\'locale\'|\'path\'|\'prompt\'|\'quiet\'|\'require\'|\'skip-packages\'|\'skip-plugins\'|\'skip-themes\'|\'ssh\'|\'ssh-args\'|\'url\'|\'user\'|int, array|bool|string|null>|bool|string|null', WP_CLI::get_config( $nullable_key ) ); + +/** @var string $string_key */ +$string_key = 'path'; +assertType( 'array|bool|string|null', WP_CLI::get_config( $string_key ) ); + // Invalid key assertType( 'null', WP_CLI::get_config( 'invalid_key' ) );