diff --git a/.github/workflows/bare_run.yaml b/.github/workflows/bare_run.yaml
index c357dd942..3e7ef3725 100644
--- a/.github/workflows/bare_run.yaml
+++ b/.github/workflows/bare_run.yaml
@@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php_version: ['7.4', '8.0', '8.2']
+ php_version: ['7.4', '8.0', '8.2', '8.4']
steps:
- uses: actions/checkout@v3
diff --git a/.github/workflows/code_analysis.yaml b/.github/workflows/code_analysis.yaml
index 4cc5a10dc..f4b4e0f26 100644
--- a/.github/workflows/code_analysis.yaml
+++ b/.github/workflows/code_analysis.yaml
@@ -38,7 +38,7 @@ jobs:
-
name: 'Check Active Classes'
- run: vendor/bin/class-leak check src --ansi
+ run: vendor/bin/class-leak check src --ansi --skip-type "PHPStan\Type\DynamicMethodReturnTypeExtension" --skip-type "PHPStan\Type\DynamicFunctionReturnTypeExtension"
name: ${{ matrix.actions.name }}
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index a663510d1..2eb65cdb1 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,24 @@ parameters:
+Want sharper type inference? The return type extensions are **disabled by default** — enable the ones that fit your stack:
+
+```yaml
+parameters:
+ symfonyReturnType: true
+ laravelReturnType: true
+ pathStrings: true
+```
+
+`symfonyReturnType` resolves `$container->get(SomeService::class)` to `SomeService` and Symfony Finder's `$splFileInfo->getRealPath()` to `string`. `laravelReturnType` does the same for Laravel's `$container->make(SomeService::class)`. `pathStrings` narrows `getcwd()`, `dirname()` and `realpath()` to `string`:
+
+```php
+$service = $container->get(SomeService::class);
+// $service is now known as SomeService, instead of plain object
+```
+
+
+
But at start, make baby steps with one rule at a time:
Jump to: [Symfony-specific rules](#3-symfony-specific-rules), [Doctrine-specific rules](#2-doctrine-specific-rules), [PHPUnit-specific rules](#4-phpunit-specific-rules) or [PHPUnit mock rules](#5-phpunit-mock-rules).
@@ -2966,4 +2984,51 @@ final class SomeTest extends TestCase
+## 6. Type Extensions and Error Formatter
+
+These extensions were merged from the now-deprecated [`symplify/phpstan-extensions`](https://github.com/symplify/phpstan-extensions)
+package. They load automatically once `phpstan/extension-installer` is set up - no extra
+configuration is needed.
+
+
+
+### `symplify` Error Formatter
+
+A compact error format with pre-escaped, regex-ready messages that are easy to copy into
+your `ignoreErrors` list. File paths are printed with line numbers and stay clickable in
+the terminal (works best with [anthraxx/intellij-awesome-console](https://github.com/anthraxx/intellij-awesome-console)).
+
+Enable it in your `phpstan.neon`:
+
+```yaml
+parameters:
+ errorFormat: symplify
+```
+
+or on the command line:
+
+```bash
+vendor/bin/phpstan analyse --error-format symplify
+```
+
+
+
+### Type Extensions
+
+Always-on return type extensions that sharpen PHPStan inference for common framework calls:
+
+* **`ContainerGetReturnTypeExtension`** - `$container->get(SomeService::class)` returns
+ `SomeService` instead of plain `object` (Symfony `ContainerInterface`).
+
+* **`LaravelContainerMakeTypeExtension`** - `$container->make(SomeService::class)` and
+ `->get(SomeService::class)` return `SomeService` (Laravel `Illuminate\Container\Container`).
+
+* **`SplFileInfoTolerantReturnTypeExtension`** - `$splFileInfo->getRealPath()` returns
+ `string` instead of `string|false`, as Symfony Finder only yields existing files.
+
+* **`NativeFunctionReturnTypeExtension`** - `getcwd()`, `dirname()` and `realpath()` return
+ `string` instead of `string|false`.
+
+
+
Happy coding!
diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php
index 493806f2a..3c80d4932 100644
--- a/composer-dependency-analyser.php
+++ b/composer-dependency-analyser.php
@@ -14,6 +14,15 @@
// optional classes
->ignoreUnknownClasses(['Symfony\Component\ExpressionLanguage\Expression'])
+ // windows-only function used in Terminal helper
+ ->ignoreUnknownFunctions(['sapi_windows_vt100_support'])
+
+ // used only by the Symfony Finder SplFileInfo return type extension
+ ->ignoreErrorsOnPackage('symfony/finder', [ErrorType::SHADOW_DEPENDENCY])
+
+ // extension that runs on Laravel Container only
+ ->ignoreErrorsOnPackage('illuminate/container', [ErrorType::DEV_DEPENDENCY_IN_PROD])
+
// already in phpstan/phpstan
->ignoreErrorsOnPackage('nikic/php-parser', [ErrorType::DEV_DEPENDENCY_IN_PROD])
->ignoreErrorsOnPackage('symfony/routing', [ErrorType::SHADOW_DEPENDENCY])
diff --git a/composer.json b/composer.json
index 1f1ef2994..f35def090 100644
--- a/composer.json
+++ b/composer.json
@@ -4,8 +4,8 @@
"description": "Set of Symplify rules for PHPStan",
"license": "MIT",
"require": {
- "php": ">=8.4",
- "webmozart/assert": " ^2.0",
+ "php": "^8.4",
+ "webmozart/assert": "^2.0",
"phpstan/phpstan": "^2.2",
"nette/utils": "^4.1",
"phpstan/phpdoc-parser": "^2.3"
@@ -14,11 +14,11 @@
"nikic/php-parser": "^5.7",
"phpunit/phpunit": "^13.0",
"symfony/framework-bundle": "^6.2",
- "symplify/easy-coding-standard": "^13.0",
+ "illuminate/container": "^11.0",
+ "phpecs/phpecs": "^2.2",
"tomasvotruba/class-leak": "^2.1",
"rector/rector": "^2.4",
"phpstan/extension-installer": "^1.4",
- "symplify/phpstan-extensions": "^12.0",
"tomasvotruba/unused-public": "^2.2",
"tomasvotruba/type-coverage": "^2.2",
"shipmonk/composer-dependency-analyser": "^1.8",
@@ -61,7 +61,8 @@
"includes": [
"config/services/services.neon",
"config/ctor-rules.neon",
- "config/mock-rules.neon"
+ "config/mock-rules.neon",
+ "config/phpstan-extensions.neon"
]
}
}
diff --git a/config/phpstan-extensions.neon b/config/phpstan-extensions.neon
new file mode 100644
index 000000000..5a9deab49
--- /dev/null
+++ b/config/phpstan-extensions.neon
@@ -0,0 +1,40 @@
+# return type extensions are disabled by default; enable in your phpstan.neon with:
+#
+# parameters:
+# symfonyReturnType: true
+# laravelReturnType: true
+# pathStrings: true
+
+parameters:
+ symfonyReturnType: false
+ laravelReturnType: false
+ pathStrings: false
+
+parametersSchema:
+ symfonyReturnType: bool()
+ laravelReturnType: bool()
+ pathStrings: bool()
+
+conditionalTags:
+ # Symfony Container::get($1) => $1 type
+ Symplify\PHPStanRules\ReturnTypeExtension\Symfony\ContainerGetReturnTypeExtension:
+ phpstan.broker.dynamicMethodReturnTypeExtension: %symfonyReturnType%
+ # Symfony Finder SplFileInfo::getRealPath() => string type
+ Symplify\PHPStanRules\ReturnTypeExtension\Symfony\SplFileInfoTolerantReturnTypeExtension:
+ phpstan.broker.dynamicMethodReturnTypeExtension: %symfonyReturnType%
+ # Laravel Container::make($1) => $1 type
+ Symplify\PHPStanRules\ReturnTypeExtension\Laravel\LaravelContainerMakeTypeExtension:
+ phpstan.broker.dynamicMethodReturnTypeExtension: %laravelReturnType%
+ # getcwd()/dirname()/realpath() => always "string"
+ Symplify\PHPStanRules\ReturnTypeExtension\NativeFunctionReturnTypeExtension:
+ phpstan.broker.dynamicFunctionReturnTypeExtension: %pathStrings%
+
+services:
+ # use with "errorFormat: symplify" in CLI/config
+ errorFormatter.symplify:
+ class: Symplify\PHPStanRules\ErrorFormatter\SymplifyErrorFormatter
+
+ - Symplify\PHPStanRules\ReturnTypeExtension\Symfony\ContainerGetReturnTypeExtension
+ - Symplify\PHPStanRules\ReturnTypeExtension\Symfony\SplFileInfoTolerantReturnTypeExtension
+ - Symplify\PHPStanRules\ReturnTypeExtension\Laravel\LaravelContainerMakeTypeExtension
+ - Symplify\PHPStanRules\ReturnTypeExtension\NativeFunctionReturnTypeExtension
diff --git a/phpstan.neon b/phpstan.neon
index 564c4d1d4..ba80c1d5e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,6 +1,7 @@
includes:
- config/services/services.neon
- config/naming-rules.neon
+ - config/phpstan-extensions.neon
parameters:
treatPhpDocTypesAsCertain: false
@@ -46,7 +47,12 @@ parameters:
-
message: '#Generator expects value type array, list\|PHPStan\\TrinaryLogic\|string\|null> given#'
- path: tests/ReturnTypeExtension/NodeGetAttributeTypeExtension/NodeGetAttributeTypeExtensionTest.php
+ path: tests/ReturnTypeExtension/*
+
+ # PHPStan testing helper, intentionally extended
+ -
+ message: '#is not covered by backward compatibility promise#'
+ path: tests/ErrorFormatter/SymplifyErrorFormatterTest.php
- '#Although PHPStan\\Node\\InClassNode is covered by backward compatibility promise, this instanceof assumption might break because (.*?) not guaranteed to always stay the same#'
- '#PHPStan\\DependencyInjection\\NeonAdapter#'
diff --git a/src/Console/Terminal.php b/src/Console/Terminal.php
new file mode 100644
index 000000000..3240b6c4e
--- /dev/null
+++ b/src/Console/Terminal.php
@@ -0,0 +1,153 @@
+>
+ */
+ private const array DESCRIPTORSPEC = [
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ private static ?int $width = null;
+
+ private static ?bool $stty = null;
+
+ public static function getWidth(): int
+ {
+ $width = \getenv('COLUMNS');
+ if ($width !== \false) {
+ return (int) \trim($width);
+ }
+
+ if (self::$width === null) {
+ self::initDimensions();
+ }
+
+ return self::$width ?: 80;
+ }
+
+ private static function hasSttyAvailable(): bool
+ {
+ if (self::$stty !== null) {
+ return self::$stty;
+ }
+
+ if (! \function_exists('exec')) {
+ return \false;
+ }
+
+ \exec('stty 2>&1', $output, $exitcode);
+ return self::$stty = $exitcode === 0;
+ }
+
+ private static function initDimensions(): void
+ {
+ $consoleMode = self::getConsoleMode();
+
+ if ('\\' === \DIRECTORY_SEPARATOR) {
+ $width = self::getAnsiconWidth();
+ if ($width !== null) {
+ self::$width = $width;
+ } elseif (! self::hasVt100Support() && self::hasSttyAvailable()) {
+ self::initDimensionsUsingStty();
+ } elseif ($consoleMode) {
+ self::$width = $consoleMode[0];
+ }
+ } else {
+ self::initDimensionsUsingStty();
+ }
+ }
+
+ private static function hasVt100Support(): bool
+ {
+ if (! \function_exists('sapi_windows_vt100_support')) {
+ return \false;
+ }
+
+ $stdoutStream = \fopen('php://stdout', 'w');
+ if ($stdoutStream === \false) {
+ return \false;
+ }
+
+ return \sapi_windows_vt100_support($stdoutStream);
+ }
+
+ private static function initDimensionsUsingStty(): void
+ {
+ $sttyColumns = self::getSttyColumns();
+
+ if ($sttyColumns) {
+ if (\preg_match('#rows.(\d+);.columns.(\d+);#i', $sttyColumns, $matches)) {
+ self::$width = (int) $matches[2];
+ } elseif (\preg_match('#;.(\d+).rows;.(\d+).columns#i', $sttyColumns, $matches)) {
+ self::$width = (int) $matches[2];
+ }
+ }
+ }
+
+ /**
+ * @return array{0: int, 1: int}|null
+ */
+ private static function getConsoleMode(): ?array
+ {
+ $info = self::readFromProcess('mode CON');
+ if ($info === null) {
+ return null;
+ }
+
+ if (! \preg_match('#--------+\r?\n.+?(\d+)\r?\n.+?(\d+)\r?\n#', $info, $matches)) {
+ return null;
+ }
+
+ return [(int) $matches[2], (int) $matches[1]];
+ }
+
+ private static function getSttyColumns(): ?string
+ {
+ return self::readFromProcess('stty -a | grep columns');
+ }
+
+ private static function readFromProcess(string $command): ?string
+ {
+ if (! \function_exists('proc_open')) {
+ return null;
+ }
+
+ $process = \proc_open($command, self::DESCRIPTORSPEC, $pipes, null, null, [
+ 'suppress_errors' => \true,
+ ]);
+ if (! \is_resource($process)) {
+ return null;
+ }
+
+ $info = \stream_get_contents($pipes[1]);
+ \fclose($pipes[1]);
+ \fclose($pipes[2]);
+ \proc_close($process);
+ return $info;
+ }
+
+ private static function getAnsiconWidth(): ?int
+ {
+ $ansicon = \getenv('ANSICON');
+ if (! is_string($ansicon)) {
+ return null;
+ }
+
+ if (\preg_match('#^(\d+)x(\d+)(?: \((\d+)x(\d+)\))?$#', \trim($ansicon), $matches)) {
+ return (int) $matches[1];
+ }
+
+ return null;
+ }
+}
diff --git a/src/Enum/ResultStatus.php b/src/Enum/ResultStatus.php
new file mode 100644
index 000000000..d4c9f84e7
--- /dev/null
+++ b/src/Enum/ResultStatus.php
@@ -0,0 +1,12 @@
+.*?)(\s+\(in context.*?)?$#';
+
+ private ?Output $output = null;
+
+ /**
+ * @return ResultStatus::*
+ */
+ public function formatErrors(AnalysisResult $analysisResult, Output $output): int
+ {
+ $outputStyle = $output->getStyle();
+ $this->output = $output;
+
+ if ($analysisResult->getTotalErrorsCount() === 0 && $analysisResult->getWarnings() === []) {
+ $outputStyle->success('No errors');
+ return ResultStatus::SUCCESS;
+ }
+
+ $this->reportErrors($analysisResult, $outputStyle);
+
+ $notFileSpecificErrors = $analysisResult->getNotFileSpecificErrors();
+ foreach ($notFileSpecificErrors as $notFileSpecificError) {
+ $outputStyle->warning($notFileSpecificError);
+ }
+
+ $warnings = $analysisResult->getWarnings();
+ foreach ($warnings as $warning) {
+ $outputStyle->warning($warning);
+ }
+
+ return ResultStatus::FAILURE;
+ }
+
+ private function reportErrors(AnalysisResult $analysisResult, OutputStyle $outputStyle): void
+ {
+ if ($analysisResult->getFileSpecificErrors() === []) {
+ return;
+ }
+
+ foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) {
+ $this->printSingleError($fileSpecificError, $outputStyle);
+ }
+
+ $outputStyle->newLine();
+
+ $errorMessage = sprintf('Found %d errors', $analysisResult->getTotalErrorsCount());
+ $outputStyle->error($errorMessage);
+ }
+
+ private function separator(): void
+ {
+ $separator = str_repeat('-', Terminal::getWidth() - self::BULGARIAN_CONSTANT);
+ $this->writeln($separator);
+ }
+
+ private function getRelativePath(string $filePath): string
+ {
+ // remove trait clutter
+ /** @var string $clearFilePath */
+ $clearFilePath = preg_replace(self::FILE_WITH_TRAIT_CONTEXT_REGEX, '$1', $filePath);
+
+ if (! file_exists($clearFilePath)) {
+ return $clearFilePath;
+ }
+
+ return FilesystemHelper::resolveFromCwd($clearFilePath);
+ }
+
+ private function regexMessage(string $message): string
+ {
+ // remove extra ".", that is really not part of message
+ $message = rtrim($message, '.');
+ return '#' . preg_quote($message, '#') . '#';
+ }
+
+ private function writeln(string $separator): void
+ {
+ $this->output?->writeLineFormatted(' ' . $separator);
+ }
+
+ private function printSingleError(Error $error, OutputStyle $outputStyle): void
+ {
+ $this->separator();
+
+ $relativeFilePath = $this->getRelativePath($error->getFile());
+ $relativeLine = $relativeFilePath . ':' . $error->getLine();
+
+ // template error
+ $templateFilePath = $error->getMetadata()['template_file_path'] ?? null;
+ $templateLine = $error->getMetadata()['template_line'] ?? null;
+
+ if ($templateFilePath && $templateLine) {
+ $templateFileLine = $this->getRelativePath($templateFilePath) . ':' . $templateLine;
+ $this->writeln($templateFileLine);
+
+ $this->writeln('rendered in: ' . $relativeLine);
+ $this->separator();
+ } else {
+ // clickable path
+ $this->writeln(' ' . $relativeLine);
+ $this->separator();
+ }
+
+ // ignored path - @todo include file
+ $regexMessage = $this->regexMessage($error->getMessage());
+ $itemMessage = sprintf(" - '%s'", $regexMessage);
+ $this->writeln($itemMessage);
+
+ if ($error->getIdentifier() !== null && $error->canBeIgnored()) {
+ $this->writeln(' 🪪 ' . $error->getIdentifier());
+ }
+
+ $this->separator();
+ $outputStyle->newLine();
+ }
+}
diff --git a/src/FileSystem/FilesystemHelper.php b/src/FileSystem/FilesystemHelper.php
new file mode 100644
index 000000000..38e1faecd
--- /dev/null
+++ b/src/FileSystem/FilesystemHelper.php
@@ -0,0 +1,17 @@
+make() return type
+ *
+ * @see \Symplify\PHPStanRules\Tests\ReturnTypeExtension\LaravelContainerMakeTypeExtension\LaravelContainerMakeTypeExtensionTest
+ */
+final class LaravelContainerMakeTypeExtension implements DynamicMethodReturnTypeExtension
+{
+ public function getClass(): string
+ {
+ return Container::class;
+ }
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ return in_array($methodReflection->getName(), ['make', 'get'], true);
+ }
+
+ public function getTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope
+ ): ?Type {
+ return ClassConstFetchReturnTypeResolver::resolve($methodReflection, $methodCall);
+ }
+}
diff --git a/src/ReturnTypeExtension/NativeFunctionReturnTypeExtension.php b/src/ReturnTypeExtension/NativeFunctionReturnTypeExtension.php
new file mode 100644
index 000000000..45146577f
--- /dev/null
+++ b/src/ReturnTypeExtension/NativeFunctionReturnTypeExtension.php
@@ -0,0 +1,31 @@
+getName(), ['getcwd', 'dirname', 'realpath'], true);
+ }
+
+ public function getTypeFromFunctionCall(
+ FunctionReflection $functionReflection,
+ FuncCall $funcCall,
+ Scope $scope
+ ): Type {
+ return new StringType();
+ }
+}
diff --git a/src/ReturnTypeExtension/Symfony/ContainerGetReturnTypeExtension.php b/src/ReturnTypeExtension/Symfony/ContainerGetReturnTypeExtension.php
new file mode 100644
index 000000000..7f9698075
--- /dev/null
+++ b/src/ReturnTypeExtension/Symfony/ContainerGetReturnTypeExtension.php
@@ -0,0 +1,39 @@
+getName() === 'get';
+ }
+
+ public function getTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope
+ ): ?Type {
+ return ClassConstFetchReturnTypeResolver::resolve($methodReflection, $methodCall);
+ }
+}
diff --git a/src/ReturnTypeExtension/Symfony/SplFileInfoTolerantReturnTypeExtension.php b/src/ReturnTypeExtension/Symfony/SplFileInfoTolerantReturnTypeExtension.php
new file mode 100644
index 000000000..62023d7d7
--- /dev/null
+++ b/src/ReturnTypeExtension/Symfony/SplFileInfoTolerantReturnTypeExtension.php
@@ -0,0 +1,42 @@
+getRealPath() has no added value. Just pollutes code and config and makes it unreadable.
+ *
+ * This narrows validation only to custom created SplFileInfo.
+ *
+ * @see \Symplify\PHPStanRules\Tests\ReturnTypeExtension\SplFileInfoTolerantReturnTypeExtension\SplFileInfoTolerantReturnTypeExtensionTest
+ */
+final class SplFileInfoTolerantReturnTypeExtension implements DynamicMethodReturnTypeExtension
+{
+ public function getClass(): string
+ {
+ return SplFileInfo::class;
+ }
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ return $methodReflection->getName() === 'getRealPath';
+ }
+
+ public function getTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope
+ ): Type {
+ return new StringType();
+ }
+}
diff --git a/src/TypeResolver/ClassConstFetchReturnTypeResolver.php b/src/TypeResolver/ClassConstFetchReturnTypeResolver.php
new file mode 100644
index 000000000..b3c4e6f06
--- /dev/null
+++ b/src/TypeResolver/ClassConstFetchReturnTypeResolver.php
@@ -0,0 +1,46 @@
+args[0])) {
+ return null;
+ }
+
+ $firstArgOrVariadciPlaceholder = $methodCall->args[0];
+ if (! $firstArgOrVariadciPlaceholder instanceof Arg) {
+ return new MixedType();
+ }
+
+ $firstValue = $firstArgOrVariadciPlaceholder->value;
+ if (! $firstValue instanceof ClassConstFetch) {
+ return new MixedType();
+ }
+
+ $className = null;
+ if ($firstValue->class instanceof Name && ($firstValue->name instanceof Identifier && $firstValue->name->toString() === 'class')) {
+ $className = $firstValue->class->toString();
+ }
+
+ if ($className !== null) {
+ return new ObjectType($className);
+ }
+
+ return null;
+ }
+}
diff --git a/tests/ErrorFormatter/Fixture/expected_single_message_many_files_report.txt b/tests/ErrorFormatter/Fixture/expected_single_message_many_files_report.txt
new file mode 100644
index 000000000..be51a3c04
--- /dev/null
+++ b/tests/ErrorFormatter/Fixture/expected_single_message_many_files_report.txt
@@ -0,0 +1,10 @@
+ ---------------------------%s
+ /data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php:4
+ ---------------------------%s
+ - '#Foo#'
+ ---------------------------%s
+
+
+ [ERROR] Found 2 errors
+
+ [WARNING] first generic error
diff --git a/tests/ErrorFormatter/SymplifyErrorFormatterTest.php b/tests/ErrorFormatter/SymplifyErrorFormatterTest.php
new file mode 100644
index 000000000..07fd440fa
--- /dev/null
+++ b/tests/ErrorFormatter/SymplifyErrorFormatterTest.php
@@ -0,0 +1,52 @@
+getByType(SymplifyErrorFormatter::class);
+
+ $analysisResult = $this->getAnalysisResult($numFileErrors, $numGenericErrors);
+ $resultCode = $symplifyErrorFormatter->formatErrors($analysisResult, $this->getOutput());
+
+ $this->assertSame($expectedExitCode, $resultCode);
+
+ $this->assertStringMatchesFormatFile($expectedOutputFile, $this->getOutputContent());
+ }
+
+ /**
+ * @return Iterator
+ */
+ public static function provideData(): Iterator
+ {
+ yield ['Some message', 1, 1, 1, __DIR__ . '/Fixture/expected_single_message_many_files_report.txt'];
+ }
+
+ /**
+ * @return string[]
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/../../config/phpstan-extensions.neon'];
+ }
+}
diff --git a/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/ContainerGetReturnTypeExtensionTest.php b/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/ContainerGetReturnTypeExtensionTest.php
new file mode 100644
index 000000000..877253ba0
--- /dev/null
+++ b/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/ContainerGetReturnTypeExtensionTest.php
@@ -0,0 +1,39 @@
+assertFileAsserts($assertType, $file, ...$args);
+ }
+
+ /**
+ * @return Iterator>
+ */
+ public static function dataAsserts(): Iterator
+ {
+ yield from self::gatherAssertTypes(__DIR__ . '/data/container_get.php.inc');
+ }
+
+ /**
+ * @return string[]
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/type_extension.neon'];
+ }
+}
diff --git a/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/Source/ExternalService.php b/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/Source/ExternalService.php
new file mode 100644
index 000000000..46f15ad08
--- /dev/null
+++ b/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/Source/ExternalService.php
@@ -0,0 +1,9 @@
+get($1) => $1 type
+ -
+ class: Symplify\PHPStanRules\ReturnTypeExtension\Symfony\ContainerGetReturnTypeExtension
+ tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
diff --git a/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/data/container_get.php.inc b/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/data/container_get.php.inc
new file mode 100644
index 000000000..7b3010774
--- /dev/null
+++ b/tests/ReturnTypeExtension/ContainerGetReturnTypeExtension/data/container_get.php.inc
@@ -0,0 +1,16 @@
+get(ExternalService::class);
+ assertType(ExternalService::class, $service);
+ }
+}
diff --git a/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/LaravelContainerMakeTypeExtensionTest.php b/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/LaravelContainerMakeTypeExtensionTest.php
new file mode 100644
index 000000000..539486dca
--- /dev/null
+++ b/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/LaravelContainerMakeTypeExtensionTest.php
@@ -0,0 +1,39 @@
+assertFileAsserts($assertType, $file, ...$args);
+ }
+
+ /**
+ * @return Iterator>
+ */
+ public static function dataAsserts(): Iterator
+ {
+ yield from self::gatherAssertTypes(__DIR__ . '/data/container_make.php.inc');
+ }
+
+ /**
+ * @return string[]
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/type_extension.neon'];
+ }
+}
diff --git a/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/Source/ExternalService.php b/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/Source/ExternalService.php
new file mode 100644
index 000000000..743961b61
--- /dev/null
+++ b/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/Source/ExternalService.php
@@ -0,0 +1,9 @@
+make($1) => $1 type
+ -
+ class: Symplify\PHPStanRules\ReturnTypeExtension\Laravel\LaravelContainerMakeTypeExtension
+ tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
diff --git a/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/data/container_make.php.inc b/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/data/container_make.php.inc
new file mode 100644
index 000000000..e191ee809
--- /dev/null
+++ b/tests/ReturnTypeExtension/LaravelContainerMakeTypeExtension/data/container_make.php.inc
@@ -0,0 +1,19 @@
+make(ExternalService::class);
+ assertType(ExternalService::class, $service);
+
+ $service = $container->get(ExternalService::class);
+ assertType(ExternalService::class, $service);
+ }
+}
diff --git a/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/NativeFunctionReturnTypeExtensionTest.php b/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/NativeFunctionReturnTypeExtensionTest.php
new file mode 100644
index 000000000..8d5e17c88
--- /dev/null
+++ b/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/NativeFunctionReturnTypeExtensionTest.php
@@ -0,0 +1,39 @@
+assertFileAsserts($assertType, $file, ...$args);
+ }
+
+ /**
+ * @return Iterator>
+ */
+ public static function dataAsserts(): Iterator
+ {
+ yield from self::gatherAssertTypes(__DIR__ . '/data/native_function.php.inc');
+ }
+
+ /**
+ * @return string[]
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/type_extension.neon'];
+ }
+}
diff --git a/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/config/type_extension.neon b/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/config/type_extension.neon
new file mode 100644
index 000000000..251a8894a
--- /dev/null
+++ b/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/config/type_extension.neon
@@ -0,0 +1,5 @@
+services:
+ # getcwd()/dirname()/realpath() => always "string"
+ -
+ class: Symplify\PHPStanRules\ReturnTypeExtension\NativeFunctionReturnTypeExtension
+ tags: [phpstan.broker.dynamicFunctionReturnTypeExtension]
diff --git a/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/data/native_function.php.inc b/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/data/native_function.php.inc
new file mode 100644
index 000000000..ca3616568
--- /dev/null
+++ b/tests/ReturnTypeExtension/NativeFunctionReturnTypeExtension/data/native_function.php.inc
@@ -0,0 +1,8 @@
+assertFileAsserts($assertType, $file, ...$args);
+ }
+
+ /**
+ * @return Iterator>
+ */
+ public static function dataAsserts(): Iterator
+ {
+ yield from self::gatherAssertTypes(__DIR__ . '/data/spl_file_info_real_path.php.inc');
+ }
+
+ /**
+ * @return string[]
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/type_extension.neon'];
+ }
+}
diff --git a/tests/ReturnTypeExtension/SplFileInfoTolerantReturnTypeExtension/config/type_extension.neon b/tests/ReturnTypeExtension/SplFileInfoTolerantReturnTypeExtension/config/type_extension.neon
new file mode 100644
index 000000000..bb6fb3d49
--- /dev/null
+++ b/tests/ReturnTypeExtension/SplFileInfoTolerantReturnTypeExtension/config/type_extension.neon
@@ -0,0 +1,5 @@
+services:
+ # $splFileInfo->getRealPath() => string type
+ -
+ class: Symplify\PHPStanRules\ReturnTypeExtension\Symfony\SplFileInfoTolerantReturnTypeExtension
+ tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
diff --git a/tests/ReturnTypeExtension/SplFileInfoTolerantReturnTypeExtension/data/spl_file_info_real_path.php.inc b/tests/ReturnTypeExtension/SplFileInfoTolerantReturnTypeExtension/data/spl_file_info_real_path.php.inc
new file mode 100644
index 000000000..0106c030e
--- /dev/null
+++ b/tests/ReturnTypeExtension/SplFileInfoTolerantReturnTypeExtension/data/spl_file_info_real_path.php.inc
@@ -0,0 +1,15 @@
+getRealPath();
+ assertType('string', $realPath);
+ }
+}