diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 89a1884ceb8..82a3d074ea6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -181,6 +181,9 @@ 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()) { + $unsealed[1] = new NeverType(true); + } } elseif (BleedingEdgeToggle::isBleedingEdge()) { $never = new NeverType(true); $unsealed = [$never, $never]; 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..0dc44d2ba63 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/Bug14844Test.php @@ -0,0 +1,78 @@ + + */ +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__ . '/../../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 + { + // 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 [ + __DIR__ . '/../../../../conf/bleedingEdge.neon', + ]; + } + +} 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()]), + ); + } + +} 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..ac4dd8c0613 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php @@ -0,0 +1,119 @@ += 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 + * + * @impure + */ +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])); + } + +}