Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
41 changes: 41 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14844.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php // lint >= 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<string>
*/
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()),
);
}

}
78 changes: 78 additions & 0 deletions tests/PHPStan/Rules/Functions/Bug14844Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PHPStan\Rules\FunctionCallParametersCheck;
use PHPStan\Rules\NullsafeCheck;
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
use PHPStan\Rules\Properties\PropertyReflectionFinder;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<CallToFunctionParametersRule>
*/
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',
];
}

}
39 changes: 39 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-14844-class-const.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php // lint >= 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<string>
*/
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()]),
);
}

}
119 changes: 119 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-14844-siblings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php // lint >= 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]));
}

}
Loading