From a0deb9e2f61a25ebce75edf5663c1c45c3185fdd Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 20 Jun 2026 02:02:12 +0700 Subject: [PATCH 1/5] [DeadCode] Skip constant compare from vendor/ on NarrowWideUnionReturnTypeRector --- .../skip_constant_compare_from_vendor.php.inc | 16 ++++++ .../NarrowWideUnionReturnTypeRector.php | 49 ++++++++++++++++++- stubs/Monolog/vendor/monolog/LogRecord.php | 13 +++++ stubs/Monolog/vendor/monolog/v2/Logger.php | 14 ++++++ stubs/Monolog/vendor/monolog/v3/Logger.php | 14 ++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc create mode 100644 stubs/Monolog/vendor/monolog/LogRecord.php create mode 100644 stubs/Monolog/vendor/monolog/v2/Logger.php create mode 100644 stubs/Monolog/vendor/monolog/v3/Logger.php diff --git a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc new file mode 100644 index 00000000000..9cd3ad8e7ea --- /dev/null +++ b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc @@ -0,0 +1,16 @@ + $level] + : new LogRecord(); + } +} diff --git a/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php b/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php index a7a11b1a215..3bf62833614 100644 --- a/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php +++ b/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php @@ -7,8 +7,10 @@ use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\Ternary; use PhpParser\Node\FunctionLike; use PhpParser\Node\Name; use PhpParser\Node\NullableType; @@ -19,6 +21,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\Reflection\ClassReflection; use PHPStan\Type\NullType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType as PHPStanUnionType; @@ -30,6 +33,7 @@ use Rector\PHPStanStaticTypeMapper\Enum\TypeKind; use Rector\Rector\AbstractRector; use Rector\Reflection\ReflectionResolver; +use Rector\Skipper\FileSystem\PathNormalizer; use Rector\StaticTypeMapper\StaticTypeMapper; use Rector\TypeDeclaration\TypeInferer\SilentVoidResolver; use Rector\ValueObject\PhpVersionFeature; @@ -255,12 +259,55 @@ private function collectActualReturnTypes(array $returnStatements): array continue; } - $returnTypes[] = $this->nodeTypeResolver->getNativeType($returnStatement->expr); + $returnTypes = [...$returnTypes, ...$this->resolveNativeReturnTypes($returnStatement->expr)]; } return $returnTypes; } + /** + * @return Type[] + */ + private function resolveNativeReturnTypes(Expr $expr): array + { + if (! $expr instanceof Ternary || ! $this->hasVendorClassConstFetch($expr->cond)) { + return [$this->nodeTypeResolver->getNativeType($expr)]; + } + + $ifExpr = $expr->if instanceof Expr ? $expr->if : $expr->cond; + + return [...$this->resolveNativeReturnTypes($ifExpr), ...$this->resolveNativeReturnTypes($expr->else)]; + } + + private function hasVendorClassConstFetch(Expr $expr): bool + { + $classConstFetches = $this->betterNodeFinder->findInstanceOf($expr, ClassConstFetch::class); + + foreach ($classConstFetches as $classConstFetch) { + $classType = $this->nodeTypeResolver->getType($classConstFetch->class); + if (! $classType instanceof ObjectType) { + continue; + } + + $classReflection = $classType->getClassReflection(); + if (! $classReflection instanceof ClassReflection) { + continue; + } + + $fileName = $classReflection->getFileName(); + if ($fileName === null) { + continue; + } + + $normalizedFileName = PathNormalizer::normalize($fileName); + if (str_contains($normalizedFileName, '/vendor/')) { + return true; + } + } + + return false; + } + /** * @param Type[] $actualReturnTypes * @return Type[] diff --git a/stubs/Monolog/vendor/monolog/LogRecord.php b/stubs/Monolog/vendor/monolog/LogRecord.php new file mode 100644 index 00000000000..e65e3a5effa --- /dev/null +++ b/stubs/Monolog/vendor/monolog/LogRecord.php @@ -0,0 +1,13 @@ + Date: Sat, 20 Jun 2026 02:05:15 +0700 Subject: [PATCH 2/5] eof --- stubs/Monolog/vendor/monolog/LogRecord.php | 2 +- stubs/Monolog/vendor/monolog/v2/Logger.php | 2 +- stubs/Monolog/vendor/monolog/v3/Logger.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stubs/Monolog/vendor/monolog/LogRecord.php b/stubs/Monolog/vendor/monolog/LogRecord.php index e65e3a5effa..edad0c23abd 100644 --- a/stubs/Monolog/vendor/monolog/LogRecord.php +++ b/stubs/Monolog/vendor/monolog/LogRecord.php @@ -10,4 +10,4 @@ class LogRecord { -} \ No newline at end of file +} diff --git a/stubs/Monolog/vendor/monolog/v2/Logger.php b/stubs/Monolog/vendor/monolog/v2/Logger.php index b16c47c7876..a8553f3d7ec 100644 --- a/stubs/Monolog/vendor/monolog/v2/Logger.php +++ b/stubs/Monolog/vendor/monolog/v2/Logger.php @@ -11,4 +11,4 @@ class Logger { public const API = 2; -} \ No newline at end of file +} diff --git a/stubs/Monolog/vendor/monolog/v3/Logger.php b/stubs/Monolog/vendor/monolog/v3/Logger.php index 91344b12c2b..15eb1da930a 100644 --- a/stubs/Monolog/vendor/monolog/v3/Logger.php +++ b/stubs/Monolog/vendor/monolog/v3/Logger.php @@ -11,4 +11,4 @@ class Logger { public const API = 3; -} \ No newline at end of file +} From a6cac0fb6bd188904ad27a821acbf0c5a5ee2e98 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 20 Jun 2026 18:32:55 +0700 Subject: [PATCH 3/5] check on composer.json has multiple majors --- .../Source/composer.json | 5 + .../config/configured_rule.php | 7 + .../NarrowWideUnionReturnTypeRector.php | 8 +- .../ComposerJsonPackageVersionResolver.php | 135 ++++++++++++++++++ .../monolog/{ => monolog/src}/LogRecord.php | 0 .../monolog/{v2 => monolog/src}/Logger.php | 0 stubs/Monolog/vendor/monolog/v3/Logger.php | 14 -- ...ComposerJsonPackageVersionResolverTest.php | 34 +++++ .../multi_major.json | 5 + .../single_major.json | 5 + 10 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json create mode 100644 src/Composer/ComposerJsonPackageVersionResolver.php rename stubs/Monolog/vendor/monolog/{ => monolog/src}/LogRecord.php (100%) rename stubs/Monolog/vendor/monolog/{v2 => monolog/src}/Logger.php (100%) delete mode 100644 stubs/Monolog/vendor/monolog/v3/Logger.php create mode 100644 tests/Composer/ComposerJsonPackageVersionResolverTest.php create mode 100644 tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json create mode 100644 tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json diff --git a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json new file mode 100644 index 00000000000..9c2eb41b878 --- /dev/null +++ b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "^2.0 || ^3.0" + } +} diff --git a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php index d869bb77c80..e64ea99b6e6 100644 --- a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php +++ b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php @@ -2,11 +2,18 @@ declare(strict_types=1); +use Rector\Composer\ComposerJsonPackageVersionResolver; use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\FunctionLike\NarrowWideUnionReturnTypeRector; use Rector\ValueObject\PhpVersionFeature; return static function (RectorConfig $rectorConfig): void { + $rectorConfig->singleton( + ComposerJsonPackageVersionResolver::class, + static fn (): ComposerJsonPackageVersionResolver => new ComposerJsonPackageVersionResolver( + __DIR__ . '/../Source/composer.json' + ) + ); $rectorConfig->rule(NarrowWideUnionReturnTypeRector::class); $rectorConfig->phpVersion(PhpVersionFeature::UNION_TYPES); }; diff --git a/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php b/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php index 3bf62833614..99ffb859742 100644 --- a/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php +++ b/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php @@ -28,12 +28,12 @@ use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo; use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory; use Rector\BetterPhpDocParser\PhpDocManipulator\PhpDocTypeChanger; +use Rector\Composer\ComposerJsonPackageVersionResolver; use Rector\NodeTypeResolver\PHPStan\Type\TypeFactory; use Rector\PhpParser\Node\BetterNodeFinder; use Rector\PHPStanStaticTypeMapper\Enum\TypeKind; use Rector\Rector\AbstractRector; use Rector\Reflection\ReflectionResolver; -use Rector\Skipper\FileSystem\PathNormalizer; use Rector\StaticTypeMapper\StaticTypeMapper; use Rector\TypeDeclaration\TypeInferer\SilentVoidResolver; use Rector\ValueObject\PhpVersionFeature; @@ -53,7 +53,8 @@ public function __construct( private readonly SilentVoidResolver $silentVoidResolver, private readonly PhpDocTypeChanger $phpDocTypeChanger, private readonly PhpDocInfoFactory $phpDocInfoFactory, - private readonly TypeFactory $typeFactory + private readonly TypeFactory $typeFactory, + private readonly ComposerJsonPackageVersionResolver $composerJsonPackageVersionResolver ) { } @@ -299,8 +300,7 @@ private function hasVendorClassConstFetch(Expr $expr): bool continue; } - $normalizedFileName = PathNormalizer::normalize($fileName); - if (str_contains($normalizedFileName, '/vendor/')) { + if ($this->composerJsonPackageVersionResolver->hasPackageMultiMajorVersions($fileName)) { return true; } } diff --git a/src/Composer/ComposerJsonPackageVersionResolver.php b/src/Composer/ComposerJsonPackageVersionResolver.php new file mode 100644 index 00000000000..4f4a1f257d7 --- /dev/null +++ b/src/Composer/ComposerJsonPackageVersionResolver.php @@ -0,0 +1,135 @@ + + */ + private ?array $packageVersionConstraints = null; + + private readonly string $composerJsonFilePath; + + public function __construct(?string $composerJsonFilePath = null) + { + $this->composerJsonFilePath = $composerJsonFilePath ?? getcwd() . '/composer.json'; + } + + public function hasPackageMultiMajorVersions(string $filePath): bool + { + $packageName = $this->resolvePackageName($filePath); + if ($packageName === null) { + return false; + } + + $versionConstraint = $this->resolvePackageVersionConstraints()[$packageName] ?? null; + if ($versionConstraint === null) { + return false; + } + + $versionConstraintParts = preg_split('#\s*\|\|?\s*#', $versionConstraint); + if ($versionConstraintParts === false || count($versionConstraintParts) < 2) { + return false; + } + + $majorVersions = []; + $versionParser = new VersionParser(); + + foreach ($versionConstraintParts as $versionConstraintPart) { + if (preg_match('#\d+#', $versionConstraintPart) !== 1) { + continue; + } + + try { + $constraint = $versionParser->parseConstraints($versionConstraintPart); + } catch (UnexpectedValueException) { + continue; + } + + $lowerMajorVersion = $this->resolveMajorVersion($constraint->getLowerBound()->getVersion()); + if ($lowerMajorVersion === null) { + continue; + } + + $majorVersions[$lowerMajorVersion] = true; + } + + return count($majorVersions) > 1; + } + + private function resolvePackageName(string $filePath): ?string + { + $normalizedFilePath = PathNormalizer::normalize($filePath); + $vendorPosition = strpos($normalizedFilePath, '/vendor/'); + + if ($vendorPosition === false) { + return null; + } + + $vendorRelativePath = substr($normalizedFilePath, $vendorPosition + strlen('/vendor/')); + $pathParts = explode('/', $vendorRelativePath); + + if (count($pathParts) < 2) { + return null; + } + + return $pathParts[0] . '/' . $pathParts[1]; + } + + /** + * @return array + */ + private function resolvePackageVersionConstraints(): array + { + if ($this->packageVersionConstraints !== null) { + return $this->packageVersionConstraints; + } + + if (! file_exists($this->composerJsonFilePath)) { + return $this->packageVersionConstraints = []; + } + + $composerJson = JsonFileSystem::readFilePath($this->composerJsonFilePath); + $packageVersionConstraints = []; + + foreach (['require', 'require-dev'] as $requireKey) { + $requires = $composerJson[$requireKey] ?? []; + if (! is_array($requires)) { + continue; + } + + foreach ($requires as $packageName => $versionConstraint) { + if (! is_string($packageName)) { + continue; + } + if (! is_string($versionConstraint)) { + continue; + } + $packageVersionConstraints[$packageName] = $versionConstraint; + } + } + + return $this->packageVersionConstraints = $packageVersionConstraints; + } + + private function resolveMajorVersion(string $version): ?int + { + $match = preg_match('#^(\d+)\.#', $version, $matches); + if ($match !== 1) { + return null; + } + + return (int) $matches[1]; + } +} diff --git a/stubs/Monolog/vendor/monolog/LogRecord.php b/stubs/Monolog/vendor/monolog/monolog/src/LogRecord.php similarity index 100% rename from stubs/Monolog/vendor/monolog/LogRecord.php rename to stubs/Monolog/vendor/monolog/monolog/src/LogRecord.php diff --git a/stubs/Monolog/vendor/monolog/v2/Logger.php b/stubs/Monolog/vendor/monolog/monolog/src/Logger.php similarity index 100% rename from stubs/Monolog/vendor/monolog/v2/Logger.php rename to stubs/Monolog/vendor/monolog/monolog/src/Logger.php diff --git a/stubs/Monolog/vendor/monolog/v3/Logger.php b/stubs/Monolog/vendor/monolog/v3/Logger.php deleted file mode 100644 index 15eb1da930a..00000000000 --- a/stubs/Monolog/vendor/monolog/v3/Logger.php +++ /dev/null @@ -1,14 +0,0 @@ -hasPackageMultiMajorVersions( + '/project/vendor/monolog/monolog/src/Logger.php' + ); + + $this->assertSame($expected, $hasPackageMultiMajorVersions); + } + + /** + * @return Iterator<(array|array)> + */ + public static function provideData(): Iterator + { + yield [__DIR__ . '/Fixture/ComposerJsonPackageVersionResolver/single_major.json', false]; + yield [__DIR__ . '/Fixture/ComposerJsonPackageVersionResolver/multi_major.json', true]; + } +} diff --git a/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json new file mode 100644 index 00000000000..9c2eb41b878 --- /dev/null +++ b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "^2.0 || ^3.0" + } +} diff --git a/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json new file mode 100644 index 00000000000..93f0b9fb0aa --- /dev/null +++ b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "^2.0" + } +} From a295b56674f8e5701cb3f3bdfb616d869b73a180 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 20 Jun 2026 11:34:37 +0000 Subject: [PATCH 4/5] [ci-review] Rector Rectify --- src/Composer/ComposerJsonPackageVersionResolver.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Composer/ComposerJsonPackageVersionResolver.php b/src/Composer/ComposerJsonPackageVersionResolver.php index 4f4a1f257d7..1719e9b4803 100644 --- a/src/Composer/ComposerJsonPackageVersionResolver.php +++ b/src/Composer/ComposerJsonPackageVersionResolver.php @@ -113,9 +113,11 @@ private function resolvePackageVersionConstraints(): array if (! is_string($packageName)) { continue; } + if (! is_string($versionConstraint)) { continue; } + $packageVersionConstraints[$packageName] = $versionConstraint; } } From 2ee0a4416b484109f11fdb4652ae2f29c98cc245 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 20 Jun 2026 21:55:43 +0700 Subject: [PATCH 5/5] cache packageName resolve --- .../ComposerJsonPackageVersionResolver.php | 27 ++++++++++++++++--- ...ComposerJsonPackageVersionResolverTest.php | 12 +++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Composer/ComposerJsonPackageVersionResolver.php b/src/Composer/ComposerJsonPackageVersionResolver.php index 1719e9b4803..5bc5556e4c2 100644 --- a/src/Composer/ComposerJsonPackageVersionResolver.php +++ b/src/Composer/ComposerJsonPackageVersionResolver.php @@ -19,6 +19,16 @@ final class ComposerJsonPackageVersionResolver */ private ?array $packageVersionConstraints = null; + /** + * @var array + */ + private array $packageMultiMajorVersions = []; + + /** + * @var array + */ + private array $packageNamesByFilePath = []; + private readonly string $composerJsonFilePath; public function __construct(?string $composerJsonFilePath = null) @@ -33,6 +43,13 @@ public function hasPackageMultiMajorVersions(string $filePath): bool return false; } + return $this->packageMultiMajorVersions[$packageName] ??= $this->resolveHasPackageMultiMajorVersions( + $packageName + ); + } + + private function resolveHasPackageMultiMajorVersions(string $packageName): bool + { $versionConstraint = $this->resolvePackageVersionConstraints()[$packageName] ?? null; if ($versionConstraint === null) { return false; @@ -70,21 +87,25 @@ public function hasPackageMultiMajorVersions(string $filePath): bool private function resolvePackageName(string $filePath): ?string { + if (array_key_exists($filePath, $this->packageNamesByFilePath)) { + return $this->packageNamesByFilePath[$filePath]; + } + $normalizedFilePath = PathNormalizer::normalize($filePath); $vendorPosition = strpos($normalizedFilePath, '/vendor/'); if ($vendorPosition === false) { - return null; + return $this->packageNamesByFilePath[$filePath] = null; } $vendorRelativePath = substr($normalizedFilePath, $vendorPosition + strlen('/vendor/')); $pathParts = explode('/', $vendorRelativePath); if (count($pathParts) < 2) { - return null; + return $this->packageNamesByFilePath[$filePath] = null; } - return $pathParts[0] . '/' . $pathParts[1]; + return $this->packageNamesByFilePath[$filePath] = $pathParts[0] . '/' . $pathParts[1]; } /** diff --git a/tests/Composer/ComposerJsonPackageVersionResolverTest.php b/tests/Composer/ComposerJsonPackageVersionResolverTest.php index 4a53cf387ca..ecb4a3d2308 100644 --- a/tests/Composer/ComposerJsonPackageVersionResolverTest.php +++ b/tests/Composer/ComposerJsonPackageVersionResolverTest.php @@ -21,6 +21,18 @@ public function test(string $composerJsonFilePath, bool $expected): void ); $this->assertSame($expected, $hasPackageMultiMajorVersions); + $this->assertSame( + $expected, + $composerJsonPackageVersionResolver->hasPackageMultiMajorVersions( + '/project/vendor/monolog/monolog/src/Logger.php' + ) + ); + $this->assertSame( + $expected, + $composerJsonPackageVersionResolver->hasPackageMultiMajorVersions( + '/project/vendor/monolog/monolog/src/Handler/StreamHandler.php' + ) + ); } /**