Fix #14129: Support integer range keys in constant arrays#4951
Fix #14129: Support integer range keys in constant arrays#4951phpstan-bot wants to merge 10 commits into2.1.xfrom
Conversation
- When setting a constant array offset with a finite union of constant string types or a finite IntegerRangeType, produce a union of constant arrays instead of degrading to a general array type - Added resolveFiniteScalarKeyTypes() helper on ConstantArrayType to extract constant key types from string unions and integer ranges - Limited expansion to CHUNK_FINITE_TYPES_LIMIT (5) keys to avoid combinatorial explosion in loop fixpoint analysis - Excluded integer constant unions (e.g., 0|1 from loops) to prevent regression in loop variable tracking - Updated constant-array-type-set.php test expectations for more precise results with int<0,4> range offsets
tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php
Show resolved
Hide resolved
|
@phpstan-bot address comments |
|
Claude finished @staabm's task in 5m 34s —— View job Addressing review commentsAll review comments have been addressed and pushed in commit 3e19a98:
Note: I was unable to run PHPUnit tests locally due to sandbox restrictions on PHP execution. CI will validate the changes. |
- Remove unnecessary toArrayKey() calls in resolveFiniteScalarKeyTypes() - Use ConstantArrayTypeBuilder instead of recursion in setOffsetValueType() - Remove CLAUDE.md changes - Add test case for int<0, 5>|int<10, 15> union of integer ranges Co-authored-by: Markus Staab <staabm@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@phpstan-bot address comments of my last review and rebase the branch |
Addressing review comments and rebasing
|
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
There was a problem hiding this comment.
this test cases are already covered with tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php
Automated fix attempt 1 for CI failures.
| @@ -0,0 +1,24 @@ | |||
| <?php declare(strict_types = 1); | |||
There was a problem hiding this comment.
this bug should be covered via CallMethodsRuleTest instead
|
@phpstan-bot rebase branch and resolve the remaining unresolved comments |
|
Claude finished @staabm's task in 5m 24s —— View job Addressing review comments and rebase
All code changes have been pushed in commit 4277198. |
- Remove bug-14129.php (already covered by set-constant-union-offset-on-constant-array.php) - Move bug-7978.php from NSRT to CallMethodsRuleTest - Add de-duplication for overlapping integer ranges in resolveFiniteScalarKeyTypes() - Add test for overlapping ranges (int<0,3>|int<2,4>) Co-authored-by: Markus Staab <staabm@users.noreply.github.com>
| /** | ||
| * @return list<ConstantIntegerType|ConstantStringType>|null | ||
| */ | ||
| private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array | ||
| { | ||
| $offsetType = $offsetType->toArrayKey(); | ||
|
|
||
| // Handle unions of constant string types (e.g. 'a'|'b') | ||
| $constantStrings = $offsetType->getConstantStrings(); | ||
| if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) { | ||
| $result = []; | ||
| foreach ($constantStrings as $constantString) { | ||
| $scalarValues = $constantString->getConstantScalarValues(); | ||
| if (count($scalarValues) !== 1) { | ||
| return null; | ||
| } | ||
| if (is_int($scalarValues[0])) { | ||
| $result[] = new ConstantIntegerType($scalarValues[0]); | ||
| } elseif (is_string($scalarValues[0])) { | ||
| $result[] = new ConstantStringType($scalarValues[0]); | ||
| } else { | ||
| return null; | ||
| } | ||
| } | ||
| return $result; | ||
| } | ||
|
|
||
| // Handle integer range types (e.g. int<1,5>) | ||
| $integerRanges = TypeUtils::getIntegerRanges($offsetType); | ||
| if (count($integerRanges) > 0) { | ||
| $finiteScalarTypes = []; | ||
| $seen = []; | ||
| foreach ($integerRanges as $integerRange) { | ||
| $finiteTypes = $integerRange->getFiniteTypes(); | ||
| if ($finiteTypes === []) { | ||
| return null; | ||
| } | ||
|
|
||
| foreach ($finiteTypes as $finiteType) { | ||
| if (isset($seen[$finiteType->getValue()])) { | ||
| continue; | ||
| } | ||
| $seen[$finiteType->getValue()] = true; | ||
| $finiteScalarTypes[] = $finiteType; | ||
| } | ||
| } | ||
|
|
||
| if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) { | ||
| return null; | ||
| } | ||
|
|
||
| return $finiteScalarTypes; | ||
| } | ||
|
|
||
| return null; | ||
| } |
There was a problem hiding this comment.
| /** | |
| * @return list<ConstantIntegerType|ConstantStringType>|null | |
| */ | |
| private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array | |
| { | |
| $offsetType = $offsetType->toArrayKey(); | |
| // Handle unions of constant string types (e.g. 'a'|'b') | |
| $constantStrings = $offsetType->getConstantStrings(); | |
| if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) { | |
| $result = []; | |
| foreach ($constantStrings as $constantString) { | |
| $scalarValues = $constantString->getConstantScalarValues(); | |
| if (count($scalarValues) !== 1) { | |
| return null; | |
| } | |
| if (is_int($scalarValues[0])) { | |
| $result[] = new ConstantIntegerType($scalarValues[0]); | |
| } elseif (is_string($scalarValues[0])) { | |
| $result[] = new ConstantStringType($scalarValues[0]); | |
| } else { | |
| return null; | |
| } | |
| } | |
| return $result; | |
| } | |
| // Handle integer range types (e.g. int<1,5>) | |
| $integerRanges = TypeUtils::getIntegerRanges($offsetType); | |
| if (count($integerRanges) > 0) { | |
| $finiteScalarTypes = []; | |
| $seen = []; | |
| foreach ($integerRanges as $integerRange) { | |
| $finiteTypes = $integerRange->getFiniteTypes(); | |
| if ($finiteTypes === []) { | |
| return null; | |
| } | |
| foreach ($finiteTypes as $finiteType) { | |
| if (isset($seen[$finiteType->getValue()])) { | |
| continue; | |
| } | |
| $seen[$finiteType->getValue()] = true; | |
| $finiteScalarTypes[] = $finiteType; | |
| } | |
| } | |
| if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) { | |
| return null; | |
| } | |
| return $finiteScalarTypes; | |
| } | |
| return null; | |
| } | |
| /** @return array<ConstantIntegerType|ConstantStringType>|null */ | |
| private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array | |
| { | |
| $result = []; | |
| $offsetType = $offsetType->toArrayKey(); | |
| $constantStrings = $offsetType->getConstantStrings(); | |
| if (count($constantStrings) > 0) { | |
| foreach ($constantStrings as $constantString) { | |
| $scalarValues = $constantString->getConstantScalarValues(); | |
| if (count($scalarValues) !== 1) { | |
| return null; | |
| } | |
| if (is_int($scalarValues[0])) { | |
| $result[] = new ConstantIntegerType($scalarValues[0]); | |
| } elseif (is_string($scalarValues[0])) { | |
| $result[] = new ConstantStringType($scalarValues[0]); | |
| } else { | |
| return null; | |
| } | |
| } | |
| } else { | |
| $integerRanges = TypeUtils::getIntegerRanges($offsetType); | |
| foreach ($integerRanges as $integerRange) { | |
| $finiteTypes = $integerRange->getFiniteTypes(); | |
| if ($finiteTypes === []) { | |
| return null; | |
| } | |
| foreach ($finiteTypes as $finiteType) { | |
| $result[$finiteType->getValue()] = $finiteType; | |
| } | |
| } | |
| } | |
| if (count($result) >= 2 && count($result) <= self::CHUNK_FINITE_TYPES_LIMIT) { | |
| return $result; | |
| } | |
| return null; | |
| } |
|
moving to a new branch in which I can commit: #4952 |

Summary
When assigning to a constant array with a union of constant string keys (e.g.,
'a'|'b') or a finite integer range (e.g.,int<1,5>), PHPStan previously degraded to a generalArrayType, losing the constant array shape information. This change produces a union of constant arrays instead, preserving precise type information.For example, given
@param array{foo: int} $aand@param int<1,5> $intRange:Changes
src/Type/Constant/ConstantArrayType.php:setOffsetValueType()to detect when the offset type can be expanded to a finite set of constant keys with at least one new key, and produce a union of constant arraysresolveFiniteScalarKeyTypes()private helper method that resolves offset types to individual constant key types from string unions and integer rangesCHUNK_FINITE_TYPES_LIMIT(5) new keys to avoid combinatorial explosionIntegerRangeType(not integer constant unions like0|1which typically come from loop fixpoint analysis)tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php: New regression test covering string union keys, integer range keys, infinite ranges, and existing keystests/PHPStan/Analyser/nsrt/constant-array-type-set.php: Updated test expectation forint<0, 4>offset on 3-element array to reflect more precise union resultCLAUDE.md: Added documentation about the new union expansion behaviorRoot cause
ConstantArrayTypeBuilder::setOffsetValueType()handles non-constant offset types by expanding them to constant scalars and checking if all match existing keys. When some keys were new (not in the array), it fell through to a degradation path that produced a generalArrayType. The fix intercepts this at theConstantArrayType::setOffsetValueType()level, creating a union of arrays for each possible key before the builder can degrade.The expansion is carefully limited to avoid regressions:
IntegerRangeTypetrigger expansion (not integer constant unions which are common in loop analysis)CHUNK_FINITE_TYPES_LIMIT(5) new keys are expandedTest
Added
tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.phpwith four test cases:doFoo: String union offset'a'|'b'onarray{foo: int}→ union of two constant arraysdoBar: Integer rangeint<1,5>onarray{foo: int}→ union of five constant arraysdoInfiniteRange: Infinite rangeint<0, max>→ falls back to general array (no expansion)doExistingKeys: Rangeint<0,1>onarray{0: 'a', 1: 'b'}→ handled by builder directlyFixes phpstan/phpstan#14129
Closes phpstan/phpstan#13000
Closes phpstan/phpstan#13759
Closes phpstan/phpstan#2294
Closes phpstan/phpstan#7978
Closes phpstan/phpstan#9907
Closes #4528
Closes #4520
Closes phpstan/phpstan#14129