diff --git a/composer.json b/composer.json
index 508784fac..92030c054 100644
--- a/composer.json
+++ b/composer.json
@@ -15,8 +15,12 @@
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.3.1",
"phpcompatibility/php-compatibility": "dev-develop",
- "phpstan/extension-installer": "^1.4.3",
+ "phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^1.12.26",
+ "phpstan/phpstan-deprecation-rules": "^1.2",
+ "phpstan/phpstan-phpunit": "^1.4",
+ "phpstan/phpstan-strict-rules": "^1.6",
+ "swissspidy/phpstan-no-private": "^0.2.1",
"szepeviktor/phpstan-wordpress": "^v1.3.5",
"wp-cli/config-command": "^1 || ^2",
"wp-cli/core-command": "^1 || ^2",
@@ -41,6 +45,11 @@
"branch-alias": {
"dev-main": "4.0.x-dev"
},
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
"readme": {
"sections": [
"Using",
@@ -58,6 +67,11 @@
"WP_CLI\\Tests\\": "src"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "WP_CLI\\Tests\\Tests\\": "tests/tests"
+ }
+ },
"minimum-stability": "dev",
"prefer-stable": true,
"bin": [
diff --git a/extension.neon b/extension.neon
new file mode 100644
index 000000000..c231d5465
--- /dev/null
+++ b/extension.neon
@@ -0,0 +1,51 @@
+services:
+ -
+ class: WP_CLI\Tests\PHPStan\ParseUrlFunctionDynamicReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicFunctionReturnTypeExtension
+ -
+ class: WP_CLI\Tests\PHPStan\GetFlagValueFunctionDynamicReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicFunctionReturnTypeExtension
+ -
+ class: WP_CLI\Tests\PHPStan\WPCliRuncommandDynamicReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicStaticMethodReturnTypeExtension
+parameters:
+ dynamicConstantNames:
+ - FOO
+ strictRules:
+ allRules: false
+ disallowedLooseComparison: false
+ booleansInConditions: false
+ uselessCast: false
+ requireParentConstructorCall: false
+ disallowedConstructs: false
+ overwriteVariablesWithLoop: false
+ closureUsesThis: false
+ matchingInheritedMethodNames: false
+ numericOperandsInArithmeticOperators: false
+ strictCalls: false
+ switchConditionsMatchingType: false
+ noVariableVariables: false
+ strictArrayFilter: false
+
+# Add the schema from phpstan-strict-rules so it's available without loading the extension
+# and the above configuration works.
+parametersSchema:
+ strictRules: structure([
+ allRules: anyOf(bool(), arrayOf(bool())),
+ disallowedLooseComparison: anyOf(bool(), arrayOf(bool())),
+ booleansInConditions: anyOf(bool(), arrayOf(bool()))
+ uselessCast: anyOf(bool(), arrayOf(bool()))
+ requireParentConstructorCall: anyOf(bool(), arrayOf(bool()))
+ disallowedConstructs: anyOf(bool(), arrayOf(bool()))
+ overwriteVariablesWithLoop: anyOf(bool(), arrayOf(bool()))
+ closureUsesThis: anyOf(bool(), arrayOf(bool()))
+ matchingInheritedMethodNames: anyOf(bool(), arrayOf(bool()))
+ numericOperandsInArithmeticOperators: anyOf(bool(), arrayOf(bool()))
+ strictCalls: anyOf(bool(), arrayOf(bool()))
+ switchConditionsMatchingType: anyOf(bool(), arrayOf(bool()))
+ noVariableVariables: anyOf(bool(), arrayOf(bool()))
+ strictArrayFilter: anyOf(bool(), arrayOf(bool()))
+ ])
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 24888b4b8..a0ff88646 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -61,6 +61,20 @@
tests/phpstan/scan-files.php
+
+ tests/data/*
+ src/PHPStan/*
+ tests/tests/PHPStan/*
+
+
+
+ tests/data/*
+
+
+
+ src/PHPStan/*
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 01b2d732d..3c2a0976c 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -1,8 +1,12 @@
+includes:
+ - extension.neon
parameters:
level: 6
paths:
- src
- tests
+ excludePaths:
+ - tests/data
scanDirectories:
- vendor/wp-cli/wp-cli
- vendor/phpunit/php-code-coverage
@@ -11,9 +15,13 @@ parameters:
- tests/phpstan/scan-files.php
treatPhpDocTypesAsCertain: false
dynamicConstantNames:
- - WP_DEBUG
- - WP_DEBUG_LOG
- - WP_DEBUG_DISPLAY
+ - WP_DEBUG
+ - WP_DEBUG_LOG
+ - WP_DEBUG_DISPLAY
ignoreErrors:
# Needs fixing in WP-CLI.
- message: '#Parameter \#1 \$cmd of function WP_CLI\\Utils\\esc_cmd expects array#'
+ - message: '#Dynamic call to static method#'
+ path: 'tests/tests'
+ strictRules:
+ strictCalls: true
diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php
index eb42da697..d726369fa 100644
--- a/src/Context/FeatureContext.php
+++ b/src/Context/FeatureContext.php
@@ -29,6 +29,8 @@
/**
* Features context.
+ *
+ * @phpstan-ignore class.implementsDeprecatedInterface
*/
class FeatureContext implements SnippetAcceptingContext {
diff --git a/src/Context/GivenStepDefinitions.php b/src/Context/GivenStepDefinitions.php
index 3df2d5324..670ae5ba3 100644
--- a/src/Context/GivenStepDefinitions.php
+++ b/src/Context/GivenStepDefinitions.php
@@ -66,7 +66,7 @@ public function given_a_specific_directory( $empty_or_nonexistent, $dir ): void
);
}
- $this->remove_dir( $dir );
+ self::remove_dir( $dir );
if ( 'empty' === $empty_or_nonexistent ) {
mkdir( $dir, 0777, true /*recursive*/ );
}
diff --git a/src/Context/ThenStepDefinitions.php b/src/Context/ThenStepDefinitions.php
index 9ab0477f9..9dc877b8e 100644
--- a/src/Context/ThenStepDefinitions.php
+++ b/src/Context/ThenStepDefinitions.php
@@ -566,6 +566,7 @@ public function then_an_email_should_be_sent( $expected ): void {
* @param int $return_code Expected HTTP status code.
*/
public function then_the_http_status_code_should_be( $return_code ): void {
+ // @phpstan-ignore staticMethod.deprecatedClass
$response = Requests::request( 'http://localhost:8080' );
$this->assert_equals( $return_code, $response->status_code );
}
diff --git a/src/PHPStan/GetFlagValueFunctionDynamicReturnTypeExtension.php b/src/PHPStan/GetFlagValueFunctionDynamicReturnTypeExtension.php
new file mode 100644
index 000000000..57941e980
--- /dev/null
+++ b/src/PHPStan/GetFlagValueFunctionDynamicReturnTypeExtension.php
@@ -0,0 +1,114 @@
+getName() === 'WP_CLI\Utils\get_flag_value';
+ }
+
+ public function getTypeFromFunctionCall(
+ FunctionReflection $functionReflection,
+ FuncCall $functionCall,
+ Scope $scope
+ ): Type {
+ $args = $functionCall->getArgs();
+
+ if ( count( $args ) < 2 ) {
+ // Not enough arguments, fall back to the function's declared return type.
+ return $functionReflection->getVariants()[0]->getReturnType();
+ }
+
+ $assocArgsType = $scope->getType( $args[0]->value );
+ $flagArgType = $scope->getType( $args[1]->value );
+
+ // 2. Determine the default type
+ $defaultType = isset( $args[2] ) ? $scope->getType( $args[2]->value ) : new NullType();
+
+ $flagConstantStrings = $flagArgType->getConstantStrings();
+
+ if ( count( $flagConstantStrings ) !== 1 ) {
+ // Flag name is dynamic or not a string.
+ // Return type is a union of all possible values in $assoc_args + default type.
+ return $this->getDynamicFlagFallbackType( $assocArgsType, $defaultType );
+ }
+
+ // 4. Flag is a single constant string.
+ $flagValue = $flagConstantStrings[0]->getValue();
+
+ // 4.a. If $assoc_args is a single ConstantArray:
+ $assocConstantArrays = $assocArgsType->getConstantArrays();
+ if ( count( $assocConstantArrays ) === 1 ) {
+ $assocArgsConstantArray = $assocConstantArrays[0];
+ $keyTypes = $assocArgsConstantArray->getKeyTypes();
+ $valueTypes = $assocArgsConstantArray->getValueTypes();
+ $resolvedValueType = null;
+
+ foreach ( $keyTypes as $index => $keyType ) {
+ $keyConstantStrings = $keyType->getConstantStrings();
+ if ( count( $keyConstantStrings ) === 1 && $keyConstantStrings[0]->getValue() === $flagValue ) {
+ $resolvedValueType = $valueTypes[ $index ];
+ break;
+ }
+ }
+
+ if ( null !== $resolvedValueType ) {
+ // Key definitely exists and has a resolved type.
+ return $resolvedValueType;
+ } else {
+ // Key definitely does not exist in this constant array.
+ return $defaultType;
+ }
+ }
+
+ // 4.b. $assoc_args is not a single ConstantArray (but $flagValue is known):
+ // Use getOffsetValueType for other array-like types.
+ $valueForKeyType = $assocArgsType->getOffsetValueType( new ConstantStringType( $flagValue ) );
+
+ // The key might exist, or its presence is unknown.
+ // The function returns $assoc_args[$flag] if set, otherwise $default.
+ return TypeCombinator::union( $valueForKeyType, $defaultType );
+ }
+
+ /**
+ * Handles the case where the flag name is not a single known constant string.
+ * The return type is a union of all possible values in $assocArgsType and $defaultType.
+ */
+ private function getDynamicFlagFallbackType( Type $assocArgsType, Type $defaultType ): Type {
+ $possibleValueTypes = [];
+
+ $assocConstantArrays = $assocArgsType->getConstantArrays();
+ if ( count( $assocConstantArrays ) === 1 ) { // It's one specific constant array
+ $constantArray = $assocConstantArrays[0];
+ if ( count( $constantArray->getValueTypes() ) > 0 ) {
+ $possibleValueTypes = $constantArray->getValueTypes();
+ }
+ } else {
+ $possibleValueTypes[] = new MixedType();
+ }
+
+ if ( empty( $possibleValueTypes ) ) {
+ return $defaultType;
+ }
+
+ return TypeCombinator::union( $defaultType, ...$possibleValueTypes );
+ }
+}
diff --git a/src/PHPStan/ParseUrlFunctionDynamicReturnTypeExtension.php b/src/PHPStan/ParseUrlFunctionDynamicReturnTypeExtension.php
new file mode 100644
index 000000000..55fef8974
--- /dev/null
+++ b/src/PHPStan/ParseUrlFunctionDynamicReturnTypeExtension.php
@@ -0,0 +1,169 @@
+|null */
+ private $componentTypesPairedConstants = null;
+
+ /** @var array|null */
+ private $componentTypesPairedStrings = null;
+
+ /** @var \PHPStan\Type\Type|null */
+ private $allComponentsTogetherType = null;
+
+ public function isFunctionSupported( FunctionReflection $functionReflection ): bool {
+ return $functionReflection->getName() === 'WP_CLI\Utils\parse_url';
+ }
+
+ public function getTypeFromFunctionCall( FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope ): ?Type {
+ if ( count( $functionCall->getArgs() ) < 1 ) {
+ return null;
+ }
+
+ $this->cacheReturnTypes();
+
+ $componentType = new ConstantIntegerType( -1 );
+
+ if ( count( $functionCall->getArgs() ) > 1 ) {
+ $componentType = $scope->getType( $functionCall->getArgs()[1]->value );
+
+ if ( ! $componentType->isConstantValue()->yes() ) {
+ return $this->createAllComponentsReturnType();
+ }
+
+ $componentType = $componentType->toInteger();
+
+ if ( ! $componentType instanceof ConstantIntegerType ) {
+ return $this->createAllComponentsReturnType();
+ }
+ }
+
+ $urlType = $scope->getType( $functionCall->getArgs()[0]->value );
+ if ( count( $urlType->getConstantStrings() ) > 0 ) {
+ $types = [];
+ foreach ( $urlType->getConstantStrings() as $constantString ) {
+ try {
+ // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
+ $result = @parse_url( $constantString->getValue(), $componentType->getValue() );
+ } catch ( \Error $e ) {
+ $types[] = new ConstantBooleanType( false );
+ continue;
+ }
+
+ $types[] = $scope->getTypeFromValue( $result );
+ }
+
+ return TypeCombinator::union( ...$types );
+ }
+
+ if ( $componentType->getValue() === -1 ) {
+ return TypeCombinator::union( $this->createComponentsArray(), new ConstantBooleanType( false ) );
+ }
+
+ return $this->componentTypesPairedConstants[ $componentType->getValue() ] ?? new ConstantBooleanType( false );
+ }
+
+ private function createAllComponentsReturnType(): Type {
+ if ( null === $this->allComponentsTogetherType ) {
+ $returnTypes = [
+ new ConstantBooleanType( false ),
+ new NullType(),
+ IntegerRangeType::fromInterval( 0, 65535 ),
+ new StringType(),
+ $this->createComponentsArray(),
+ ];
+
+ $this->allComponentsTogetherType = TypeCombinator::union( ...$returnTypes );
+ }
+
+ return $this->allComponentsTogetherType;
+ }
+
+ private function createComponentsArray(): Type {
+ $builder = ConstantArrayTypeBuilder::createEmpty();
+
+ if ( null === $this->componentTypesPairedStrings ) {
+ throw new \PHPStan\ShouldNotHappenException();
+ }
+
+ foreach ( $this->componentTypesPairedStrings as $componentName => $componentValueType ) {
+ $builder->setOffsetValueType( new ConstantStringType( $componentName ), $componentValueType, true );
+ }
+
+ return $builder->getArray();
+ }
+
+ private function cacheReturnTypes(): void {
+ if ( null !== $this->componentTypesPairedConstants ) {
+ return;
+ }
+
+ $stringType = new StringType();
+ $port = IntegerRangeType::fromInterval( 0, 65535 );
+ $falseType = new ConstantBooleanType( false );
+ $nullType = new NullType();
+
+ $stringOrFalseOrNull = TypeCombinator::union( $stringType, $falseType, $nullType );
+ $portOrFalseOrNull = TypeCombinator::union( $port, $falseType, $nullType );
+
+ $this->componentTypesPairedConstants = [
+ PHP_URL_SCHEME => $stringOrFalseOrNull,
+ PHP_URL_HOST => $stringOrFalseOrNull,
+ PHP_URL_PORT => $portOrFalseOrNull,
+ PHP_URL_USER => $stringOrFalseOrNull,
+ PHP_URL_PASS => $stringOrFalseOrNull,
+ PHP_URL_PATH => $stringOrFalseOrNull,
+ PHP_URL_QUERY => $stringOrFalseOrNull,
+ PHP_URL_FRAGMENT => $stringOrFalseOrNull,
+ ];
+
+ $this->componentTypesPairedStrings = [
+ 'scheme' => $stringType,
+ 'host' => $stringType,
+ 'port' => $port,
+ 'user' => $stringType,
+ 'pass' => $stringType,
+ 'path' => $stringType,
+ 'query' => $stringType,
+ 'fragment' => $stringType,
+ ];
+ }
+}
diff --git a/src/PHPStan/WPCliRuncommandDynamicReturnTypeExtension.php b/src/PHPStan/WPCliRuncommandDynamicReturnTypeExtension.php
new file mode 100644
index 000000000..379e03b5a
--- /dev/null
+++ b/src/PHPStan/WPCliRuncommandDynamicReturnTypeExtension.php
@@ -0,0 +1,192 @@
+getName() === 'runcommand';
+ }
+
+ public function getTypeFromStaticMethodCall(
+ MethodReflection $methodReflection,
+ StaticCall $methodCall,
+ Scope $scope
+ ): Type {
+ $args = $methodCall->getArgs();
+
+ /** @var ConstantBooleanType|ConstantStringType $returnOption */
+ $returnOption = new ConstantBooleanType( true );
+ /** @var ConstantBooleanType|ConstantStringType $parseOption */
+ $parseOption = new ConstantBooleanType( false );
+ /** @var ConstantBooleanType $exitOnErrorOption */
+ $exitOnErrorOption = new ConstantBooleanType( true );
+
+ $optionsAreStaticallyKnown = true;
+
+ if ( isset( $args[1] ) && $args[1] instanceof Arg ) {
+ $optionsNode = $args[1]->value;
+ $optionsType = $scope->getType( $optionsNode );
+
+ if ( $optionsType->isConstantArray()->yes() ) {
+ $constantArrayTypes = $optionsType->getConstantArrays();
+ if ( count( $constantArrayTypes ) === 1 ) {
+ $constantArrayType = $constantArrayTypes[0];
+ $keyTypes = $constantArrayType->getKeyTypes();
+ $valueTypes = $constantArrayType->getValueTypes();
+
+ foreach ( $keyTypes as $i => $keyType ) {
+ $keyConstantStrings = $keyType->getConstantStrings();
+ if ( count( $keyConstantStrings ) !== 1 ) {
+ $optionsAreStaticallyKnown = false;
+ break;
+ }
+ $keyName = $keyConstantStrings[0]->getValue();
+ $currentOptionValueType = $valueTypes[ $i ];
+
+ switch ( $keyName ) {
+ case 'return':
+ $valueConstantStrings = $currentOptionValueType->getConstantStrings();
+ if ( count( $valueConstantStrings ) === 1 && $currentOptionValueType->isScalar()->yes() ) {
+ $returnOption = $valueConstantStrings[0];
+ } elseif ( $currentOptionValueType->isTrue()->yes() ) {
+ $returnOption = new ConstantBooleanType( true );
+ } elseif ( $currentOptionValueType->isFalse()->yes() ) {
+ $returnOption = new ConstantBooleanType( false );
+ } else {
+ $optionsAreStaticallyKnown = false;
+ }
+ break;
+ case 'parse':
+ $valueConstantStrings = $currentOptionValueType->getConstantStrings();
+ $isExactlyJsonString = ( count( $valueConstantStrings ) === 1 && $valueConstantStrings[0]->getValue() === 'json' && $currentOptionValueType->isScalar()->yes() );
+
+ if ( $isExactlyJsonString ) {
+ $parseOption = $valueConstantStrings[0];
+ } elseif ( $$currentOptionValueType->isFalse()->yes() ) {
+ $parseOption = new ConstantBooleanType( false );
+ } else {
+ // Not a single, clear constant we handle for a "known" path
+ $parseOption = new ConstantBooleanType( false ); // Default effect
+ $optionsAreStaticallyKnown = false;
+ }
+ break;
+ case 'exit_error':
+ if ( $currentOptionValueType->isTrue()->yes() ) {
+ $exitOnErrorOption = new ConstantBooleanType( true );
+ } elseif ( $currentOptionValueType->isFalse()->yes() ) {
+ $exitOnErrorOption = new ConstantBooleanType( false );
+ } else {
+ $optionsAreStaticallyKnown = false;
+ }
+ break;
+ }
+ if ( ! $optionsAreStaticallyKnown ) {
+ break;
+ }
+ }
+ } else {
+ $optionsAreStaticallyKnown = false;
+ }
+ } else {
+ $optionsAreStaticallyKnown = false;
+ }
+ }
+
+ if ( ! $optionsAreStaticallyKnown ) {
+ return TypeCombinator::union( $this->getFallbackUnionTypeWithoutNever(), new NeverType() );
+ }
+
+ $normalReturnType = $this->determineNormalReturnType( $returnOption, $parseOption );
+
+ if ( $exitOnErrorOption->getValue() === true ) {
+ if ( $normalReturnType instanceof NeverType ) {
+ return $normalReturnType;
+ }
+ return TypeCombinator::union( $normalReturnType, new NeverType() );
+ }
+
+ return $normalReturnType;
+ }
+
+ /**
+ * @param ConstantBooleanType|ConstantStringType $returnOptionValue
+ * @param ConstantBooleanType|ConstantStringType $parseOptionValue
+ */
+ private function determineNormalReturnType( Type $returnOptionValue, Type $parseOptionValue ): Type {
+ $returnConstantStrings = $returnOptionValue->getConstantStrings();
+ $return_val = count( $returnConstantStrings ) === 1 ? $returnConstantStrings[0]->getValue() : null;
+
+ $parseConstantStrings = $parseOptionValue->getConstantStrings();
+ $parseIsJson = count( $parseConstantStrings ) === 1 && $parseConstantStrings[0]->getValue() === 'json';
+
+ if ( 'all' === $return_val ) {
+ return $this->createAllObjectType();
+ }
+ if ( 'return_code' === $return_val ) {
+ return new IntegerType();
+ }
+ if ( 'stderr' === $return_val ) {
+ return new StringType();
+ }
+ if ( $returnOptionValue->isTrue()->yes() || 'stdout' === $return_val ) {
+ if ( $parseIsJson ) {
+ return TypeCombinator::union(
+ new ArrayType( new MixedType(), new MixedType() ),
+ new NullType()
+ );
+ }
+ return new StringType();
+ }
+ if ( $returnOptionValue->isFalse()->yes() ) {
+ return new NullType();
+ }
+
+ return new MixedType( true );
+ }
+
+ private function createAllObjectType(): Type {
+ $propertyTypes = [
+ 'stdout' => new StringType(),
+ 'stderr' => new StringType(),
+ 'return_code' => new IntegerType(),
+ ];
+ $optionalProperties = [];
+ return new ObjectShapeType( $propertyTypes, $optionalProperties );
+ }
+
+ private function getFallbackUnionTypeWithoutNever(): Type {
+ return TypeCombinator::union(
+ new StringType(),
+ new IntegerType(),
+ $this->createAllObjectType(),
+ new ArrayType( new MixedType(), new MixedType() ),
+ new ObjectWithoutClassType(),
+ new NullType()
+ );
+ }
+}
diff --git a/tests/data/get_flag_value.php b/tests/data/get_flag_value.php
new file mode 100644
index 000000000..9a6b39c26
--- /dev/null
+++ b/tests/data/get_flag_value.php
@@ -0,0 +1,62 @@
+ 'bar',
+ 'baz' => 'qux',
+ ],
+ 'foo'
+);
+assertType( "'bar'", $value );
+
+$value = get_flag_value(
+ [
+ 'foo' => 'bar',
+ 'baz' => 'qux',
+ ],
+ 'bar'
+);
+assertType( 'null', $value );
+
+$value = get_flag_value(
+ [
+ 'foo' => 'bar',
+ 'baz' => 'qux',
+ ],
+ 'bar',
+ 123
+);
+assertType( '123', $value );
+
+$value = get_flag_value(
+ [
+ 'foo' => 'bar',
+ 'baz' => true,
+ ],
+ 'baz',
+ 123
+);
+assertType( 'true', $value );
+
+$assoc_args = [
+ 'foo' => 'bar',
+ 'baz' => true,
+];
+$key = 'baz';
+
+$value = get_flag_value( $assoc_args, $key, 123 );
+assertType( 'true', $value );
+
+$value = get_flag_value( $assoc_args, $key2, 123 );
+assertType( "123|'bar'|true", $value );
diff --git a/tests/data/parse_url.php b/tests/data/parse_url.php
new file mode 100644
index 000000000..dd5ecaded
--- /dev/null
+++ b/tests/data/parse_url.php
@@ -0,0 +1,53 @@
+, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|int<0, 65535>|string|false|null', $value );
+
+$value = parse_url( 'http://def.abc', PHP_URL_FRAGMENT );
+assertType( 'null', $value );
+
+$value = parse_url( 'http://def.abc#this-is-fragment', PHP_URL_FRAGMENT );
+assertType( "'this-is-fragment'", $value );
+
+$value = parse_url( 'http://def.abc#this-is-fragment', 9999 );
+assertType( 'false', $value );
+
+$value = parse_url( $string, 9999 );
+assertType( 'false', $value );
+
+$value = parse_url( $string, PHP_URL_PORT );
+assertType( 'int<0, 65535>|false|null', $value );
+
+$value = parse_url( $string );
+assertType( 'array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false', $value );
+
+/** @var 'http://def.abc'|'https://example.com' $union */
+$union = $union;
+assertType( "array{scheme: 'http', host: 'def.abc'}|array{scheme: 'https', host: 'example.com'}", parse_url( $union ) );
+
+/** @var 'http://def.abc#fragment1'|'https://example.com#fragment2' $union */
+$union = $union;
+assertType( "'fragment1'|'fragment2'", parse_url( $union, PHP_URL_FRAGMENT ) );
diff --git a/tests/data/runcommand.php b/tests/data/runcommand.php
new file mode 100644
index 000000000..3f1828bbe
--- /dev/null
+++ b/tests/data/runcommand.php
@@ -0,0 +1,67 @@
+ true ] );
+assertType( 'string', $value );
+
+$value = WP_CLI::runcommand( 'plugin list --format=json', [ 'return' => false ] );
+assertType( 'null', $value );
+
+$value = WP_CLI::runcommand( 'plugin list --format=json', [ 'return' => 'all' ] );
+assertType( 'object{stdout: string, stderr: string, return_code: int}', $value );
+
+$value = WP_CLI::runcommand( 'plugin list --format=json', [ 'return' => 'stdout' ] );
+assertType( 'string', $value );
+
+$value = WP_CLI::runcommand( 'plugin list --format=json', [ 'return' => 'stderr' ] );
+assertType( 'string', $value );
+
+$value = WP_CLI::runcommand( 'plugin list --format=json', [ 'return' => 'return_code' ] );
+assertType( 'int', $value );
+
+$value = WP_CLI::runcommand(
+ 'plugin list --format=json',
+ [
+ 'return' => true,
+ 'parse' => 'json',
+ ]
+);
+assertType( 'array|null', $value );
+
+$value = WP_CLI::runcommand(
+ 'plugin list --format=json',
+ [
+ 'return' => 'stdout',
+ 'parse' => 'json',
+ ]
+);
+assertType( 'array|null', $value );
+
+$value = WP_CLI::runcommand(
+ 'plugin list --format=json',
+ [
+ 'return' => 'stdout',
+ 'exit_error' => true,
+ ]
+);
+assertType( 'string', $value );
+
+$value = WP_CLI::runcommand(
+ 'plugin list --format=json',
+ [
+ 'return' => 'stdout',
+ 'exit_error' => false,
+ ]
+);
+assertType( 'string', $value );
diff --git a/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php b/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php
new file mode 100644
index 000000000..801d87527
--- /dev/null
+++ b/tests/tests/PHPStan/TestDynamicReturnTypeExtension.php
@@ -0,0 +1,33 @@
+
+ */
+ public static function dataFileAsserts(): iterable {
+ // Path to a file with actual asserts of expected types:
+ yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/parse_url.php' );
+ yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/get_flag_value.php' );
+ yield from self::gatherAssertTypes( dirname( __DIR__, 2 ) . '/data/runcommand.php' );
+ }
+
+ /**
+ * @dataProvider dataFileAsserts
+ * @param array ...$args
+ */
+ #[DataProvider( 'dataFileAsserts' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound
+ public function testFileAsserts( string $assertType, string $file, ...$args ): void {
+ $this->assertFileAsserts( $assertType, $file, ...$args );
+ }
+
+ public static function getAdditionalConfigFiles(): array {
+ return [ dirname( __DIR__, 3 ) . '/extension.neon' ];
+ }
+}
diff --git a/tests/tests/TestBehatTags.php b/tests/tests/TestBehatTags.php
index bc4468090..638f39ec4 100644
--- a/tests/tests/TestBehatTags.php
+++ b/tests/tests/TestBehatTags.php
@@ -1,5 +1,7 @@