From d3838df01c2d32933b69050b10db30de772636bf Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:20:01 +0000 Subject: [PATCH 1/8] Do not map the sealed-never sentinel value type in ConstantArrayType::mapValueType - In bleeding edge, a sealed constant array carries an `unsealed` descriptor of `[never, never]` (explicit never) meaning "no extra elements". `mapValueType` applied the callback to that sentinel value type, which for `array_map` (and the str_replace family, both routed through `mapValueType`) resolves the callback against a `never` argument and yields an `ErrorType`. - The `ErrorType` was hidden inside the sealed array's unsealed slot, so `UnresolvableTypeHelper` (which traverses the whole type) reported false-positive `function.unresolvableReturnType` / `argument.unresolvableType` errors even though the visible inferred type was correct. - Guard `mapValueType` to leave the unsealed sentinel untouched when the array is sealed (`isUnsealed()->no()`), only mapping the unsealed value type for genuinely (or maybe) unsealed arrays. - Probed the sibling transforms `traverse` / `traverseSimultaneously`, nested `array_map`, `str_replace`, generic identity pass-through, array spread and `+` union: all leaks originate from the single `mapValueType` chokepoint and are fixed at the source; the sibling traversals only propagate types and never plant the `ErrorType`. --- src/Type/Constant/ConstantArrayType.php | 8 ++- tests/PHPStan/Analyser/nsrt/bug-14844.php | 41 +++++++++++++ .../PHPStan/Rules/Functions/Bug14844Test.php | 61 +++++++++++++++++++ .../Rules/Functions/data/bug-14844.php | 35 +++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14844.php create mode 100644 tests/PHPStan/Rules/Functions/Bug14844Test.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-14844.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 89a1884ceb8..9f6c1f390a9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3186,8 +3186,12 @@ public function mapValueType(callable $cb): Type $newValueTypes[] = $cb($valueType); } - $newUnsealed = $this->unsealed === null - ? null + // A sealed array's unsealed value type is the explicit-never sentinel for + // "no extra elements". Mapping it through $cb would invent a bogus type for + // elements that cannot exist (e.g. an ErrorType from an enum property fetch + // on never), so leave the sentinel untouched. + $newUnsealed = $this->unsealed === null || $this->isUnsealed()->no() + ? $this->unsealed : [$this->unsealed[0], $cb($this->unsealed[1])]; return $this->recreate( diff --git a/tests/PHPStan/Analyser/nsrt/bug-14844.php b/tests/PHPStan/Analyser/nsrt/bug-14844.php new file mode 100644 index 00000000000..0e5640a99bb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14844.php @@ -0,0 +1,41 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14844; + +use function PHPStan\Testing\assertType; + +enum Someenum: string +{ + case FOO = 'foo'; +} + +/** + * @template TReturn + * @param callable(): TReturn $callable + * @return TReturn + */ +function doFoo(callable $callable) +{ + return $callable(); +} + +class A +{ + + /** + * @return array + */ + public function doBar(): array + { + assertType("array{'foo'}", doFoo( + fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()), + )); + + return doFoo( + fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()), + ); + } + +} diff --git a/tests/PHPStan/Rules/Functions/Bug14844Test.php b/tests/PHPStan/Rules/Functions/Bug14844Test.php new file mode 100644 index 00000000000..d5e1f951483 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/Bug14844Test.php @@ -0,0 +1,61 @@ + + */ +class Bug14844Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + $broker = self::createReflectionProvider(); + return new CallToFunctionParametersRule( + $broker, + new FunctionCallParametersCheck( + new RuleLevelHelper( + $broker, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: true, + checkImplicitMixed: true, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ), + new NullsafeCheck(), + new UnresolvableTypeHelper(), + new PropertyReflectionFinder(), + $broker, + checkArgumentTypes: true, + checkArgumentsPassedByReference: true, + checkExtraArguments: true, + checkMissingTypehints: true, + ), + ); + } + + #[RequiresPhp('>= 8.1.0')] + public function testBug14844(): void + { + $this->analyse([__DIR__ . '/data/bug-14844.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../../conf/bleedingEdge.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-14844.php b/tests/PHPStan/Rules/Functions/data/bug-14844.php new file mode 100644 index 00000000000..34b1279e846 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14844.php @@ -0,0 +1,35 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14844Rule; + +enum Someenum: string +{ + case FOO = 'foo'; +} + +/** + * @template TReturn + * @param callable(): TReturn $callable + * @return TReturn + */ +function doFoo(callable $callable) +{ + return $callable(); +} + +class A +{ + + /** + * @return array + */ + public function doBar(): array + { + return doFoo( + fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()), + ); + } + +} From dedaed7aac8d1d848aafee9bdf8e1c8bbf84c533 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 26 Jun 2026 07:27:21 +0200 Subject: [PATCH 2/8] Update ConstantArrayType.php --- src/Type/Constant/ConstantArrayType.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 9f6c1f390a9..6c0c98d80dc 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3186,10 +3186,6 @@ public function mapValueType(callable $cb): Type $newValueTypes[] = $cb($valueType); } - // A sealed array's unsealed value type is the explicit-never sentinel for - // "no extra elements". Mapping it through $cb would invent a bogus type for - // elements that cannot exist (e.g. an ErrorType from an enum property fetch - // on never), so leave the sentinel untouched. $newUnsealed = $this->unsealed === null || $this->isUnsealed()->no() ? $this->unsealed : [$this->unsealed[0], $cb($this->unsealed[1])]; From 5e29fc6135bfc3d55137b4b389ce085254272fc4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 26 Jun 2026 07:30:15 +0200 Subject: [PATCH 3/8] simplify --- .../PHPStan/Rules/Functions/Bug14844Test.php | 2 +- .../Rules/Functions/data/bug-14844.php | 35 ------------------- 2 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-14844.php diff --git a/tests/PHPStan/Rules/Functions/Bug14844Test.php b/tests/PHPStan/Rules/Functions/Bug14844Test.php index d5e1f951483..d0e847d1f68 100644 --- a/tests/PHPStan/Rules/Functions/Bug14844Test.php +++ b/tests/PHPStan/Rules/Functions/Bug14844Test.php @@ -48,7 +48,7 @@ protected function getRule(): Rule #[RequiresPhp('>= 8.1.0')] public function testBug14844(): void { - $this->analyse([__DIR__ . '/data/bug-14844.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14844.php'], []); } public static function getAdditionalConfigFiles(): array diff --git a/tests/PHPStan/Rules/Functions/data/bug-14844.php b/tests/PHPStan/Rules/Functions/data/bug-14844.php deleted file mode 100644 index 34b1279e846..00000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-14844.php +++ /dev/null @@ -1,35 +0,0 @@ -= 8.1 - -declare(strict_types = 1); - -namespace Bug14844Rule; - -enum Someenum: string -{ - case FOO = 'foo'; -} - -/** - * @template TReturn - * @param callable(): TReturn $callable - * @return TReturn - */ -function doFoo(callable $callable) -{ - return $callable(); -} - -class A -{ - - /** - * @return array - */ - public function doBar(): array - { - return doFoo( - fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases()), - ); - } - -} From 6ee0856ba3e49d2308c3b09bb87eb293141f1eff Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 26 Jun 2026 05:45:56 +0000 Subject: [PATCH 4/8] Cover sibling array operations touching the sealed unsealed sentinel Audit of every ConstantArrayType method that reads or transforms the `[never, never]` unsealed sentinel under bleeding edge: only mapValueType applies a value-projecting callback (array_map's user closure body) that can turn the never sentinel into an ErrorType. The sibling transforms (traverse, traverseSimultaneously, generalizeValues, filterArrayRemovingFalsey, changeKeyCaseArray) only map never to never, and array-rebuilding operations (array_reverse, array_values, array_merge, spread) drop and recreate the sentinel, so none of them plant an ErrorType. These regression tests lock in that behavior across the whole operation family (array_map nesting, str_replace, generic pass-through, +, spread, array_filter, array_reverse, array_values, array_merge, array_change_key_case). They fail without the mapValueType guard and pass with it. Co-Authored-By: Claude Opus 4.8 --- .../PHPStan/Rules/Functions/Bug14844Test.php | 8 ++ .../Functions/data/bug-14844-siblings.php | 117 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php diff --git a/tests/PHPStan/Rules/Functions/Bug14844Test.php b/tests/PHPStan/Rules/Functions/Bug14844Test.php index d0e847d1f68..6ed9c0fad37 100644 --- a/tests/PHPStan/Rules/Functions/Bug14844Test.php +++ b/tests/PHPStan/Rules/Functions/Bug14844Test.php @@ -51,6 +51,14 @@ public function testBug14844(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14844.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testBug14844Siblings(): void + { + // Sibling ConstantArrayType operations that also touch the sealed + // `[never, never]` unsealed sentinel must not plant an ErrorType there. + $this->analyse([__DIR__ . '/data/bug-14844-siblings.php'], []); + } + public static function getAdditionalConfigFiles(): array { return [ diff --git a/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php b/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php new file mode 100644 index 00000000000..1325c4407ca --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php @@ -0,0 +1,117 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14844Siblings; + +enum Someenum: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} + +/** + * @template TReturn + * @param callable(): TReturn $callable + * @return TReturn + */ +function doFoo(callable $callable) +{ + return $callable(); +} + +/** + * @template T + * @param T $x + * @return T + */ +function identity($x) +{ + return $x; +} + +/** + * A sealed constant array carries a `[never, never]` unsealed sentinel under + * bleeding edge. Every operation below transforms or passes through that + * sentinel via a different ConstantArrayType method (mapValueType, traverse, + * traverseSimultaneously, generalizeValues, ...). None of them may leak an + * ErrorType into the sentinel slot, which UnresolvableTypeHelper would flag as + * a bogus "contains unresolvable type" error on these generic calls. + */ +class A +{ + + public function arrayMap(): void + { + // mapValueType + doFoo(fn () => array_map(fn (Someenum $type) => $type->value, Someenum::cases())); + } + + public function nestedArrayMap(): void + { + // mapValueType applied to a mapped array + doFoo(fn () => array_map( + static fn (string $v) => strtoupper($v), + array_map(fn (Someenum $type) => $type->value, Someenum::cases()), + )); + } + + public function strReplace(): void + { + // ReplaceFunctionsDynamicReturnTypeExtension -> mapValueType + doFoo(fn () => str_replace('f', 'F', array_map(fn (Someenum $type) => $type->value, Someenum::cases()))); + } + + public function genericIdentity(): void + { + // template resolution / traverse over a sealed array passed by value + identity(array_map(fn (Someenum $type) => $type->value, Someenum::cases())); + } + + public function spread(): void + { + doFoo(fn () => [...array_map(fn (Someenum $type) => $type->value, Someenum::cases())]); + } + + public function arrayUnion(): void + { + // unionArrays + $mapped = array_map(fn (Someenum $type) => $type->value, Someenum::cases()); + doFoo(fn () => $mapped + $mapped); + } + + public function arrayReverse(): void + { + // reverseArray + identity(array_reverse(array_map(fn (Someenum $type) => $type->value, Someenum::cases()))); + } + + public function arrayValues(): void + { + // getValuesArray + identity(array_values(array_map(fn (Someenum $type) => $type->value, Someenum::cases()))); + } + + public function arrayFilter(): void + { + // filterArrayRemovingFalsey + identity(array_filter(array_map(fn (Someenum $type) => $type->value, Someenum::cases()))); + } + + public function arrayMerge(): void + { + // mergeArrays + $mapped = array_map(fn (Someenum $type) => $type->value, Someenum::cases()); + identity(array_merge($mapped, $mapped)); + } + + public function plainSealedArray(): void + { + // a plain sealed array literal also carries the sentinel + identity(['foo', 'bar']); + identity(array_reverse(['foo', 'bar'])); + identity(array_values(['foo', 'bar'])); + identity(array_change_key_case(['foo' => 1, 'bar' => 2])); + } + +} From d81b07fde95408142a2a6c628d86a0d15abd2c5f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 26 Jun 2026 07:07:11 +0000 Subject: [PATCH 5/8] Normalize the sealed unsealed sentinel value type in the constructor Centralize protection of the sealed `[never, never]` unsealed sentinel: whenever a ConstantArrayType is sealed (its unsealed key is the explicit `never`), force the unsealed value slot back to the explicit-never sentinel. This prevents any caller from planting a projected/transformed type (e.g. an ErrorType produced by mapping the sentinel as if it were a real element) into the slot, where UnresolvableTypeHelper would later flag it as a bogus "contains unresolvable type" error. This supersedes the per-call-site guard in mapValueType, which is now reverted to its simpler form since recreate() routes every transform through the constructor normalization. Co-Authored-By: Claude Opus 4.8 --- src/Type/Constant/ConstantArrayType.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 6c0c98d80dc..da83ead732c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -181,6 +181,14 @@ public function __construct( if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) { $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); } + if ($unsealed[0] instanceof NeverType && $unsealed[0]->isExplicit()) { + // Sealed sentinel (`isUnsealed()->no()`): there are no extra + // elements, so the value-type slot is not a real element type. + // Force it back to the explicit-never sentinel so no caller can + // plant a projected/transformed type (e.g. an ErrorType produced + // by mapping the sentinel as if it were an element) into it. + $unsealed[1] = new NeverType(true); + } } elseif (BleedingEdgeToggle::isBleedingEdge()) { $never = new NeverType(true); $unsealed = [$never, $never]; @@ -3186,8 +3194,8 @@ public function mapValueType(callable $cb): Type $newValueTypes[] = $cb($valueType); } - $newUnsealed = $this->unsealed === null || $this->isUnsealed()->no() - ? $this->unsealed + $newUnsealed = $this->unsealed === null + ? null : [$this->unsealed[0], $cb($this->unsealed[1])]; return $this->recreate( From 9a32cc9e5b840f24cc319e21c426bf08910ea7f3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 26 Jun 2026 07:46:37 +0000 Subject: [PATCH 6/8] Cover the class-constant variant of the sealed sentinel bug Map a class-constant fetch (`$type::FOO`) through array_map's callback instead of an enum-case property fetch. It routes through the same mapValueType chokepoint and must not plant an ErrorType in the sealed `[never, never]` unsealed sentinel. Co-Authored-By: Claude Opus 4.8 --- .../PHPStan/Rules/Functions/Bug14844Test.php | 9 +++++ .../Functions/data/bug-14844-class-const.php | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-14844-class-const.php diff --git a/tests/PHPStan/Rules/Functions/Bug14844Test.php b/tests/PHPStan/Rules/Functions/Bug14844Test.php index 6ed9c0fad37..0dc44d2ba63 100644 --- a/tests/PHPStan/Rules/Functions/Bug14844Test.php +++ b/tests/PHPStan/Rules/Functions/Bug14844Test.php @@ -51,6 +51,15 @@ public function testBug14844(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14844.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testBug14844ClassConst(): void + { + // Same bug as testBug14844, but the array_map callback fetches a class + // constant (`$type::FOO`) instead of an enum-case property. It also routes + // through mapValueType and must not plant an ErrorType in the sentinel. + $this->analyse([__DIR__ . '/data/bug-14844-class-const.php'], []); + } + #[RequiresPhp('>= 8.1.0')] public function testBug14844Siblings(): void { diff --git a/tests/PHPStan/Rules/Functions/data/bug-14844-class-const.php b/tests/PHPStan/Rules/Functions/data/bug-14844-class-const.php new file mode 100644 index 00000000000..4d1509992dd --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14844-class-const.php @@ -0,0 +1,39 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14844ClassConst; + +final class Someclass +{ + + public const FOO = 'foo'; + +} + +/** + * @template TReturn + * @param callable(): TReturn $callable + * @return TReturn + */ +function doFoo(callable $callable) +{ + return $callable(); +} + +class A +{ + + /** + * @return array + */ + public function doBar(): array + { + // Same sealed-sentinel bug as bug-14844, but the mapped value is a + // class-constant fetch (`$type::FOO`) instead of an enum-case fetch. + return doFoo( + fn () => array_map(fn (Someclass $type) => $type::FOO, [new Someclass()]), + ); + } + +} From ad405ff06e9faf882dbf2f53f987621289a1281e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 26 Jun 2026 09:47:38 +0200 Subject: [PATCH 7/8] hide unrelated errors --- tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php b/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php index 1325c4407ca..ac4dd8c0613 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php +++ b/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php @@ -24,6 +24,8 @@ function doFoo(callable $callable) * @template T * @param T $x * @return T + * + * @impure */ function identity($x) { From 252c5c858833320a3e9841ce1796065f84e81446 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 26 Jun 2026 09:47:44 +0200 Subject: [PATCH 8/8] rm comments --- src/Type/Constant/ConstantArrayType.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index da83ead732c..82a3d074ea6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -182,11 +182,6 @@ public function __construct( $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); } if ($unsealed[0] instanceof NeverType && $unsealed[0]->isExplicit()) { - // Sealed sentinel (`isUnsealed()->no()`): there are no extra - // elements, so the value-type slot is not a real element type. - // Force it back to the explicit-never sentinel so no caller can - // plant a projected/transformed type (e.g. an ErrorType produced - // by mapping the sentinel as if it were an element) into it. $unsealed[1] = new NeverType(true); } } elseif (BleedingEdgeToggle::isBleedingEdge()) {