From 986c280260ee0fa051f55fc0ace7803845b90b02 Mon Sep 17 00:00:00 2001 From: Martin Bruna Date: Thu, 8 Oct 2026 22:37:31 +0200 Subject: [PATCH 1/2] Keep optional keys of unsealed shapes in falsey isset() narrowing Removing HasOffsetType('k') from an unsealed shape like array{k?: string, ...} drops the explicit key, which re-admits it through the unsealed extras with their (wider) value type - the "narrowed" type array{...} is in fact wider. Merged back with the truthy branch it collapsed the subject to plain array, e.g. after `$a['k'] ?? ''`. Skip the optional-key removal when any union member is not sealed. Co-Authored-By: Claude Opus 5.5 --- .../Helper/DefaultNarrowingHelper.php | 13 ++++- tests/PHPStan/Analyser/Bug15428Test.php | 33 +++++++++++++ tests/PHPStan/Analyser/data/bug-15428.php | 24 ++++++++++ tests/PHPStan/Analyser/nsrt/bug-15428.php | 48 +++++++++++++++++++ .../IssetRuleWithoutBleedingEdgeTest.php | 47 ++++++++++++++++++ .../Rules/Variables/data/bug-15428.php | 17 +++++++ turbo-ext/src/DefaultNarrowingHelper.cpp | 15 +++++- 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/Bug15428Test.php create mode 100644 tests/PHPStan/Analyser/data/bug-15428.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15428.php create mode 100644 tests/PHPStan/Rules/Variables/IssetRuleWithoutBleedingEdgeTest.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-15428.php 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/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 @@ + Date: Tue, 20 Oct 2026 15:02:42 +0200 Subject: [PATCH 2/2] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 9e9e8609bea..b06beaa2150 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -34,7 +34,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '6366ed7'; + public const EXPECTED_EXTENSION_VERSION = '986c280'; private static bool $active = false;