From 38c309b84077245bf2f01f24e337436c557b0d2d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 5 Sep 2026 10:35:50 +0200 Subject: [PATCH 1/5] Merge unions of constant arrays with many keys instead of degrading processArrayTypes() numbered every distinct constant key with a power of two and degraded the whole union to a general array once the counter left the int range - a hidden cap of 62 distinct keys. The numbering's consumer (per-array bitmask bucketing) was removed in 1cdd9ba022, so all that survived was the accidental degradation, which also made large unions behave differently from small ones (the >62-key path collapsed same-key-set shapes losslessly via reduceArrays(false), while the small path preserves them as tagged unions). Keep a plain seen-key set for deduplication and let unions of constant arrays of any size take the tagged-union-preserving path; oversized unions still degrade via ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT in optimizeConstantArrays(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA --- src/Type/TypeCombinator.php | 24 ++++--------------- .../nsrt/preserve-large-constant-array.php | 13 +++++++++- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 6ecc8dd8182..bb1a21df6af 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -43,7 +43,6 @@ use function get_class; use function implode; use function in_array; -use function is_int; use function sprintf; use function usort; use const PHP_INT_MAX; @@ -995,19 +994,11 @@ private static function processArrayTypes(array $arrayTypes): array $keyTypesForGeneralArray = []; $valueTypesForGeneralArray = []; $generalArrayOccurred = false; - $constantKeyTypesNumbered = []; - $filledArrays = 0; - $overflowed = false; - - /** @var int|float $nextConstantKeyTypeIndex */ - $nextConstantKeyTypeIndex = 1; + $seenConstantKeyTypes = []; foreach ($arrayTypes as $arrayType) { $constantArrays = $arrayType->getConstantArrays(); $isConstantArray = $constantArrays !== []; - if (!$isConstantArray || !$arrayType->isIterableAtLeastOnce()->no()) { - $filledArrays++; - } if (!$isConstantArray) { foreach ($arrayType->getArrays() as $type) { @@ -1024,23 +1015,16 @@ private static function processArrayTypes(array $arrayTypes): array $valueTypesForGeneralArray[] = $valueTypes[$i]; $keyTypeValue = $keyType->getValue(); - if (array_key_exists($keyTypeValue, $constantKeyTypesNumbered)) { + if (array_key_exists($keyTypeValue, $seenConstantKeyTypes)) { continue; } + $seenConstantKeyTypes[$keyTypeValue] = true; $keyTypesForGeneralArray[] = $keyType; - - $constantKeyTypesNumbered[$keyTypeValue] = $nextConstantKeyTypeIndex; - $nextConstantKeyTypeIndex *= 2; - if (!is_int($nextConstantKeyTypeIndex)) { - $generalArrayOccurred = true; - $overflowed = true; - continue 2; - } } } } - if ($generalArrayOccurred && (!$overflowed || $filledArrays > 1)) { + if ($generalArrayOccurred) { $reducedArrayTypes = self::reduceArrays($arrayTypes, false); if (count($reducedArrayTypes) === 1) { return [self::intersect($reducedArrayTypes[0], ...$accessoryTypes)]; diff --git a/tests/PHPStan/Analyser/nsrt/preserve-large-constant-array.php b/tests/PHPStan/Analyser/nsrt/preserve-large-constant-array.php index a66425e3dd2..d1d023028dc 100644 --- a/tests/PHPStan/Analyser/nsrt/preserve-large-constant-array.php +++ b/tests/PHPStan/Analyser/nsrt/preserve-large-constant-array.php @@ -17,7 +17,7 @@ function multiKeys(array $arr): void return; } - assertType('array{1: string|null, 2: int|null, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float}', $arr); + assertType('array{1: null, 2: null, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float}|array{1: string, 2: int, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float}', $arr); echo 1; } @@ -65,3 +65,14 @@ function multipleOptions(array $arr): void assertType('array{1: string, 2: int, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float}', $brr); echo 1; } + +/** + * @param array{1: string, 2: int, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float} $full + * @param array{2: int, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float} $withoutFirst + */ +function unionOfManyKeys(array $full, array $withoutFirst): void +{ + $union = rand(0, 1) === 0 ? $full : $withoutFirst; + assertType('array{1?: string, 2: int, 3: bool, 4: string, 5: int, 6: bool, 7: string, 8: int, 9: bool, 10: string, 11: int, 12: bool, 13: string, 14: int, 15: bool, 16: string, 17: int, 18: bool, 19: string, 20: int, 21: bool, 22: string, 23: int, 24: bool, 25: string, 26: int, 27: bool, 28: string, 29: int, 30: bool, 31: string, 32: int, 33: bool, 34: string, 35: int, 36: bool, 37: string, 38: int, 39: bool, 40: string, 41: int, 42: bool, 43: string, 44: int, 45: bool, 46: string, 47: int, 48: bool, 49: string, 50: int, 51: bool, 52: string, 53: int, 54: bool, 55: string, 56: int, 57: bool, 58: string, 59: int, 60: bool, 61: string, 62: int, 63: bool, 64: float}', $union); + echo 1; +} From 7d63f7c0562d380e413278f0eeeba5f21d516f4b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 5 Sep 2026 10:36:11 +0200 Subject: [PATCH 2/5] Narrow falsey isset() on an optional constant-array offset !isset($a['k']) where 'k' is an optional key with a non-nullable value type implies the key is absent, so remove HasOffsetType('k') from the subject - the counterpart of the removal already done for keys that surely exist, and of the falsey array_key_exists() narrowing. The removal is skipped when any union member can hold null at the offset (there the key may exist with a null value). With the falsey branch actually narrowed, branch merges see differing types for the subject and the conditional-expression guard machinery picks it up, so variables assigned under if (isset($a['k'])) regain their certainty when the same isset() is re-checked later. The `??=` value scope (which already applied this helper) now unsets the target offset, and a sealed all-optional shape intersected with non-empty-array collapses to its only possible key - the updated bug-15021 and isset-constant-array expectations record both, each strictly more precise than before. Closes https://github.com/phpstan/phpstan/issues/9426 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA --- .../Helper/DefaultNarrowingHelper.php | 22 +++++++++- tests/PHPStan/Analyser/nsrt/bug-15021.php | 12 +++--- tests/PHPStan/Analyser/nsrt/bug-9426.php | 42 +++++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 9 ++++ .../PHPStan/Rules/Variables/IssetRuleTest.php | 6 ++- .../PHPStan/Rules/Variables/data/bug-9426.php | 22 ++++++++++ .../Variables/data/isset-constant-array.php | 12 ++++++ 7 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9426.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-9426.php diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index e4f783acae5..75785404df4 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -687,13 +687,31 @@ public function createIssetSingleSubjectNonTrueTypes( if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { $constantArrays = $varType->getConstantArrays(); $typesToRemove = []; + $hasOptionalNonNullableOffset = false; + $hasPossiblyNullOffsetValue = false; foreach ($constantArrays as $constantArray) { $hasOffset = $constantArray->hasOffsetValueType($dimType); - if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { + if ($hasOffset->no()) { continue; } + if (!$constantArray->getOffsetValueType($dimType)->isNull()->no()) { + $hasPossiblyNullOffsetValue = true; + continue; + } + + if ($hasOffset->yes()) { + $typesToRemove[] = $constantArray; + continue; + } + + $hasOptionalNonNullableOffset = true; + } - $typesToRemove[] = $constantArray; + // !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) { + $typesToRemove[] = new HasOffsetType($dimType); } if ($typesToRemove !== []) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15021.php b/tests/PHPStan/Analyser/nsrt/bug-15021.php index 7864d1c5d2f..c96bdefe863 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15021.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15021.php @@ -7,7 +7,7 @@ /** @param array{foo?: string, bar?: string} $data */ function optionalOffset(array $data): void { - $data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data); + $data['foo'] ??= assertType('array{bar?: string}', $data); } /** @param array{foo?: string, bar?: string} $data */ @@ -65,7 +65,7 @@ function nonNullableStaticProperty(): void function propertyOffset(Foo $foo): void { - $foo->data['foo'] ??= assertType('array{foo?: string, bar?: string}', $foo->data); + $foo->data['foo'] ??= assertType('array{bar?: string}', $foo->data); } /** @param \ArrayAccess $a */ @@ -133,14 +133,14 @@ function unsetTargetBeforeAssignOp(array $data): void function emptyTargetBeforeAssignOp(array $data): void { if (empty($data['foo'])) { - $data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data); + $data['foo'] ??= assertType('array{bar?: string}', $data); } } /** @param array{foo?: string, bar?: string} $data */ function assignOpInsideEmpty(array $data): void { - if (empty($data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data))) { + if (empty($data['foo'] ??= assertType('array{bar?: string}', $data))) { echo 'empty'; } } @@ -148,7 +148,7 @@ function assignOpInsideEmpty(array $data): void /** @param array{foo?: string, bar?: string} $data */ function assignOpInsideIsset(array $data): void { - if (isset($data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data))) { + if (isset($data['foo'] ??= assertType('array{bar?: string}', $data))) { echo 'isset'; } } @@ -157,7 +157,7 @@ function assignOpInsideIsset(array $data): void function assignOpInsideUnsetOffset(array $data): void { $other = ['x' => 1, 'fallback' => 2]; - unset($other[$data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data)]); + unset($other[$data['foo'] ??= assertType('array{bar?: string}', $data)]); } /** @param array{foo?: string, bar?: string} $data */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-9426.php b/tests/PHPStan/Analyser/nsrt/bug-9426.php new file mode 100644 index 00000000000..3242ad0ee0c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9426.php @@ -0,0 +1,42 @@ +analyse([__DIR__ . '/data/bug-10228.php'], []); } + public function testBug9426(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-9426.php'], []); + } + #[RequiresPhp('>= 8.4.0')] public function testPropertyHooks(): void { diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 61a9e57ed0f..0aaa80ca68b 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -567,9 +567,13 @@ public function testIssetConstantArray(): void 13, ], [ - 'Offset 3 on array{string, string, string, string, string} in isset() always exists and is not nullable.', + 'Offset 3 on array{0: string, 1: string, 2: string, 4: string} in isset() does not exist.', 17, ], + [ + 'Offset 3 on array{string, string, string, string, string} in isset() always exists and is not nullable.', + 29, + ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-9426.php b/tests/PHPStan/Rules/Variables/data/bug-9426.php new file mode 100644 index 00000000000..d32cfb33c73 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-9426.php @@ -0,0 +1,22 @@ + Date: Sat, 5 Sep 2026 10:36:20 +0200 Subject: [PATCH 3/5] Apply the falsey isset() narrowing of the left side to the ?? right scope The right side of `$a ?? $b` only evaluates when the left side is null or unset, but its scope was built from the null pin alone - the falsey isset() narrowing composed from the left read (offset removals, certainty reductions) only reached the value scope of `??=`. Apply it to the plain coalesce too, except when the left side is surely set and surely non-null: there the right side cannot evaluate at all, and the counterfactual certainty reductions would survive the scope merge after the coalesce. An always-initialized nullable property read on the left now pins to null on the right side, so `$a->noDefault ?? $b->noDefault` composes with earlier not-both-null narrowing - the updated isset-property-default-value expectation matches the defaulted-property case above it. Closes https://github.com/phpstan/phpstan/issues/6379 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA --- src/Analyser/ExprHandler/CoalesceHandler.php | 25 ++++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-6379.php | 30 +++++++++++++++++++ .../nsrt/isset-property-default-value.php | 2 +- ...nexistentOffsetInArrayDimFetchRuleTest.php | 5 ++++ tests/PHPStan/Rules/Arrays/data/bug-6379.php | 21 +++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6379.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-6379.php diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 6cef6945c8b..a04130b6fd7 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -55,15 +55,34 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->left); + $chainResults = []; + $this->defaultNarrowingHelper->captureChainResults($expr->left, $storage, $chainResults); + // the falsey narrowing of this very node - asking the scope about it // mid-processing would take the on-demand path and recurse - $rightScope = $scope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $scope, $expr->left, $condResult, $expr, TypeSpecifierContext::createFalsey())); + $rightSideSpecifiedTypes = $this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $scope, $expr->left, $condResult, $expr, TypeSpecifierContext::createFalsey()); + $leftSurelySetNonNull = $condResult->getIssetabilityResolution($scope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }) === true; + if (!$leftSurelySetNonNull) { + // the right side only evaluates when the left side is null or unset - + // the falsey isset() narrowing of the left side, like `??=`; skipped + // when the right side cannot evaluate at all, so its counterfactual + // certainty reductions do not survive the merge below + $rightSideSpecifiedTypes = $rightSideSpecifiedTypes->unionWith( + $this->coalesceCompositionHelper->getRightSideScopeSpecifiedTypes($scope, $expr->left, $condResult, $chainResults, $expr), + ); + } + $rightScope = $scope->applySpecifiedTypes($rightSideSpecifiedTypes); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); // the left-is-set narrowing, composed from the already-processed chain // results - the inside-out equivalent of narrowing by isset($expr->left) // without synthesizing an Isset_ node and re-walking the chain on demand - $chainResults = []; - $this->defaultNarrowingHelper->captureChainResults($expr->left, $storage, $chainResults); $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( $scope, $expr->left, diff --git a/tests/PHPStan/Analyser/nsrt/bug-6379.php b/tests/PHPStan/Analyser/nsrt/bug-6379.php new file mode 100644 index 00000000000..731dd10f7dd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6379.php @@ -0,0 +1,30 @@ +noDefault ?? $b->noDefault); + assertType('int', $a->noDefault ?? $b->noDefault); } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 9bd4e9c6b15..096a2328b0f 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1358,6 +1358,11 @@ public function testBug13688(): void $this->analyse([__DIR__ . '/data/bug-13688.php'], []); } + public function testBug6379(): void + { + $this->analyse([__DIR__ . '/data/bug-6379.php'], []); + } + public static function dataUnsealedArrayShapes(): iterable { foreach ([false, true] as $reportPossiblyNonexistentGeneralArrayOffset) { diff --git a/tests/PHPStan/Rules/Arrays/data/bug-6379.php b/tests/PHPStan/Rules/Arrays/data/bug-6379.php new file mode 100644 index 00000000000..0e2c41cf6a1 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-6379.php @@ -0,0 +1,21 @@ + Date: Sat, 5 Sep 2026 22:09:12 +0200 Subject: [PATCH 4/5] Add regression test(s) for collateral fix(es) #12786 #13075 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA --- tests/PHPStan/Analyser/nsrt/bug-12786.php | 29 +++++++++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 11 +++++ tests/PHPStan/Rules/Arrays/data/bug-13075.php | 41 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12786.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-13075.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-12786.php b/tests/PHPStan/Analyser/nsrt/bug-12786.php new file mode 100644 index 00000000000..9c40f0ade15 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12786.php @@ -0,0 +1,29 @@ +|null', $x['y'] ?? null); + + return new self( + should_be_array: $x['y'] ?? null, + ); + } +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 096a2328b0f..3f42da09b4f 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1363,6 +1363,17 @@ public function testBug6379(): void $this->analyse([__DIR__ . '/data/bug-6379.php'], []); } + public function testBug13075(): void + { + $this->reportPossiblyNonexistentConstantArrayOffset = true; + $this->analyse([__DIR__ . '/data/bug-13075.php'], [ + [ + 'Offset \'c\' might not exist on array{c?: string}.', + 40, + ], + ]); + } + public static function dataUnsealedArrayShapes(): iterable { foreach ([false, true] as $reportPossiblyNonexistentGeneralArrayOffset) { diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13075.php b/tests/PHPStan/Rules/Arrays/data/bug-13075.php new file mode 100644 index 00000000000..3f05eff9f84 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-13075.php @@ -0,0 +1,41 @@ + Date: Sat, 5 Sep 2026 22:34:34 +0200 Subject: [PATCH 5/5] Add required lint version comment to the bug-12786 fixture Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA --- tests/PHPStan/Analyser/nsrt/bug-12786.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-12786.php b/tests/PHPStan/Analyser/nsrt/bug-12786.php index 9c40f0ade15..a3b49c07346 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12786.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12786.php @@ -1,4 +1,6 @@ -= 8.0 + +declare(strict_types = 1); namespace Bug12786;