diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index a0de3ba5e22..99c8023e79d 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -729,6 +729,7 @@ public function createIssetSingleSubjectNonTrueTypes( $typesToRemove = []; $hasOptionalNonNullableOffset = false; $hasPossiblyNullOffsetValue = false; + $hasUnsealedOptionalOffset = false; foreach ($constantArrays as $constantArray) { $hasOffset = $constantArray->hasOffsetValueType($dimType); if ($hasOffset->no()) { @@ -744,13 +745,21 @@ public function createIssetSingleSubjectNonTrueTypes( continue; } + // Removing the key from an unsealed shape re-admits it through + // the unsealed extras, widening its value type instead. + if ($constantArray->isUnsealed()->yes()) { + $hasUnsealedOptionalOffset = true; + continue; + } + $hasOptionalNonNullableOffset = true; } // !isset() on an optional key with a non-nullable value means the // key is absent - but only when no member can hold null at that - // offset (the removal distributes over every union member). - if ($hasOptionalNonNullableOffset && !$hasPossiblyNullOffsetValue) { + // offset and no member is unsealed (the removal distributes over + // every union member). + if ($hasOptionalNonNullableOffset && !$hasPossiblyNullOffsetValue && !$hasUnsealedOptionalOffset) { $typesToRemove[] = new HasOffsetType($dimType); } diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 88c83cac563..7c24f3c0f17 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '28c29b8'; + public const EXPECTED_EXTENSION_VERSION = '6c18d79'; private static bool $active = false; diff --git a/tests/PHPStan/Analyser/Bug15428Test.php b/tests/PHPStan/Analyser/Bug15428Test.php new file mode 100644 index 00000000000..bfeb182b36c --- /dev/null +++ b/tests/PHPStan/Analyser/Bug15428Test.php @@ -0,0 +1,33 @@ +assertFileAsserts($assertType, $file, ...$args); + } + +} diff --git a/tests/PHPStan/Analyser/data/bug-15428.php b/tests/PHPStan/Analyser/data/bug-15428.php new file mode 100644 index 00000000000..aad64947db3 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-15428.php @@ -0,0 +1,24 @@ +} $c + */ +function typedUnsealed(array $c): void +{ + if (!isset($c['a'])) { + assertType('array{a?: string, ...}', $c); + } + assertType('array{a?: string, ...}', $c); +} + +/** + * @param array{a?: string} $c + */ +function sealed(array $c): void +{ + if (!isset($c['a'])) { + assertType('array{}', $c); + } + assertType('array{}|array{a: string}', $c); +} diff --git a/tests/PHPStan/Rules/Variables/IssetRuleWithoutBleedingEdgeTest.php b/tests/PHPStan/Rules/Variables/IssetRuleWithoutBleedingEdgeTest.php new file mode 100644 index 00000000000..e3cafdafe4d --- /dev/null +++ b/tests/PHPStan/Rules/Variables/IssetRuleWithoutBleedingEdgeTest.php @@ -0,0 +1,47 @@ + + */ +class IssetRuleWithoutBleedingEdgeTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new IssetRule( + new IssetCheck( + new PropertyDescriptor(), + true, + true, + ), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + ); + } + + public function testBug15428(): void + { + $this->analyse([__DIR__ . '/data/bug-15428.php'], [ + [ + 'Offset \'a\' on array{} in isset() does not exist.', + 15, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return []; + } + +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-15428.php b/tests/PHPStan/Rules/Variables/data/bug-15428.php new file mode 100644 index 00000000000..2d2611df436 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-15428.php @@ -0,0 +1,17 @@ +