From f2b9a4d8b1b4c61857b8073acbd6d6a164307e6c Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sat, 4 Jul 2026 15:06:07 +0200 Subject: [PATCH 1/2] Flatten deep BooleanOr chains in resolveType() to avoid O(n^2) scope narrowing resolveType() resolved the boolean type of a BooleanOr by recursing into the left operand (getType($expr->left)) and re-narrowing the whole left chain with filterByFalseyValue() at each level. For a chain of n arms that is O(n^2) (and worse) narrowing work, so a single || with many instanceof/comparison arms made analysis explode (locally, 150 arms went from ~70s to ~3.5s). Flatten the chain and iterate the arms once, threading the falsey scope arm by arm: the whole chain is true if any arm is true (given the previous arms are false), false if every arm is false, and bool otherwise. This matches the recursive result (analysis output is byte-identical) at O(n), mirroring the existing specifyTypesForFlattenedBooleanOr() on the type-specifying side. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/BooleanOrHandler.php | 46 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14477.php | 42 +++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14477.php diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index d439a2c8082..77f7744702b 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -27,6 +27,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_key_first; +use function array_key_last; use function array_merge; use function array_reverse; use function count; @@ -54,6 +55,13 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { + // For deep BooleanOr chains, resolve the boolean type by iterating the flattened arms while + // threading the falsey scope, instead of recursing into the left operand and re-narrowing the + // whole chain at each level - the latter is O(n^2) (and worse) in the number of arms. + if (BooleanAndHandler::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { + return $this->resolveTypeForFlattenedBooleanOr($scope, $expr); + } + $leftBooleanType = $scope->getType($expr->left)->toBoolean(); if ($leftBooleanType->isTrue()->yes()) { return new ConstantBooleanType(true); @@ -80,6 +88,44 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return new BooleanType(); } + /** + * The whole chain is true if any arm is true (given the previous arms are false), false if every + * arm is false, and bool otherwise. Threading the falsey scope arm by arm keeps this O(n), matching + * the recursive resolveType() result without re-narrowing the whole left chain at each level. + * + * @param BooleanOr|LogicalOr $expr + */ + private function resolveTypeForFlattenedBooleanOr(MutatingScope $scope, Expr $expr): Type + { + $arms = []; + $current = $expr; + while ($current instanceof BooleanOr || $current instanceof LogicalOr) { + $arms[] = $current->right; + $current = $current->left; + } + $arms[] = $current; + $arms = array_reverse($arms); + + $allArmsAreFalse = true; + $armScope = $scope; + $lastArmKey = array_key_last($arms); + foreach ($arms as $key => $arm) { + $armBooleanType = $armScope->getType($arm)->toBoolean(); + if ($armBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + if (!$armBooleanType->isFalse()->yes()) { + $allArmsAreFalse = false; + } + if ($key === $lastArmKey) { + continue; + } + $armScope = $armScope->filterByFalseyValue($arm); + } + + return $allArmsAreFalse ? new ConstantBooleanType(false) : new BooleanType(); + } + public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes { if (!$scope instanceof MutatingScope) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14477.php b/tests/PHPStan/Analyser/nsrt/bug-14477.php new file mode 100644 index 00000000000..52bb2121b3f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14477.php @@ -0,0 +1,42 @@ + Date: Sat, 4 Jul 2026 18:09:02 +0200 Subject: [PATCH 2/2] Add benchmark reproducing the deep BooleanOr resolveType() slowdown or-chain-resolve-type-blowup.php is a 100-arm instanceof || chain that takes the recursive resolveType() path without the flattening fix (about 8.5s here) and the flattened path with it (about 1.3s). It sits alongside the existing and-chain-truthy-blowup.php and or-chain-falsey-blowup.php benches, covering the resolveType path at the default BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/or-chain-resolve-type-blowup.php | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/bench/data/or-chain-resolve-type-blowup.php diff --git a/tests/bench/data/or-chain-resolve-type-blowup.php b/tests/bench/data/or-chain-resolve-type-blowup.php new file mode 100644 index 00000000000..58c11ddf8dd --- /dev/null +++ b/tests/bench/data/or-chain-resolve-type-blowup.php @@ -0,0 +1,117 @@ +