Skip to content

Fix #14129: Support integer range keys in constant arrays#4951

Closed
phpstan-bot wants to merge 10 commits into2.1.xfrom
create-pull-request/patch-xojripf
Closed

Fix #14129: Support integer range keys in constant arrays#4951
phpstan-bot wants to merge 10 commits into2.1.xfrom
create-pull-request/patch-xojripf

Conversation

@phpstan-bot
Copy link
Collaborator

@phpstan-bot phpstan-bot commented Feb 16, 2026

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 general ArrayType, 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} $a and @param int<1,5> $intRange:

$a[$intRange] = 256;
// Before: non-empty-array<'foo'|int<1, 5>, int>
// After:  array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}

Changes

  • src/Type/Constant/ConstantArrayType.php:
    • Modified 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 arrays
    • Added resolveFiniteScalarKeyTypes() private helper method that resolves offset types to individual constant key types from string unions and integer ranges
    • Limited expansion to CHUNK_FINITE_TYPES_LIMIT (5) new keys to avoid combinatorial explosion
    • Only expands string constant unions and IntegerRangeType (not integer constant unions like 0|1 which 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 keys
  • tests/PHPStan/Analyser/nsrt/constant-array-type-set.php: Updated test expectation for int<0, 4> offset on 3-element array to reflect more precise union result
  • CLAUDE.md: Added documentation about the new union expansion behavior

Root 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 general ArrayType. The fix intercepts this at the ConstantArrayType::setOffsetValueType() level, creating a union of arrays for each possible key before the builder can degrade.

The expansion is carefully limited to avoid regressions:

  • Only string constant unions and IntegerRangeType trigger expansion (not integer constant unions which are common in loop analysis)
  • At most CHUNK_FINITE_TYPES_LIMIT (5) new keys are expanded
  • When all keys already exist in the array, the builder handles it directly without expansion

Test

Added tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php with four test cases:

  1. doFoo: String union offset 'a'|'b' on array{foo: int} → union of two constant arrays
  2. doBar: Integer range int<1,5> on array{foo: int} → union of five constant arrays
  3. doInfiniteRange: Infinite range int<0, max> → falls back to general array (no expansion)
  4. doExistingKeys: Range int<0,1> on array{0: 'a', 1: 'b'} → handled by builder directly

Fixes 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

staabm and others added 6 commits February 16, 2026 09:47
- 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
@staabm
Copy link
Contributor

staabm commented Feb 16, 2026

@phpstan-bot address comments

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @staabm's task in 5m 34s —— View job


Addressing review comments

All review comments have been addressed and pushed in commit 3e19a98:

  • Remove unnecessary toArrayKey() calls (lines 759, 796) — Since toArrayKey() is already called at the top of resolveFiniteScalarKeyTypes(), the subsequent calls on $constantString and $scalarType were redundant. Removed both.
  • Use ConstantArrayTypeBuilder instead of recursion (line 725) — Replaced $this->setOffsetValueType($scalarKeyType, $valueType, $unionValues) with ConstantArrayTypeBuilder::createFromConstantArray($this) + setOffsetValueType + getArray().
  • Remove CLAUDE.md changes — Reverted the added documentation block about ConstantArrayType::setOffsetValueType union expansion.
  • Add test case for int<0, 5>|int<10, 15> — Added doUnionOfRanges test method. With 12 total values this exceeds CHUNK_FINITE_TYPES_LIMIT (5), so it correctly falls back to a general array type.

Note: I was unable to run PHPUnit tests locally due to sandbox restrictions on PHP execution. CI will validate the changes.
| Branch

phpstan-bot and others added 2 commits February 16, 2026 10:31
- 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>
@staabm
Copy link
Contributor

staabm commented Feb 16, 2026

@phpstan-bot address comments of my last review and rebase the branch

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Addressing review comments and rebasing

Working...

  • Gather context — read current code and review comments
  • Remove unnecessary loop in resolveFiniteScalarKeyTypes() — removed the redundant instanceof ConstantIntegerType check loop since IntegerRangeType::getFiniteTypes() already returns ConstantIntegerType[]
  • De-duplicate values in $finiteScalarTypes — added $seen tracking to skip duplicate values from overlapping ranges
  • Add test for overlapping ranges (int<0, 3>|int<2, 4>) to verify de-duplication
  • Rebase branch onto 2.1.x
  • Push changes

View job

@@ -0,0 +1,30 @@
<?php
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this bug should be covered via CallMethodsRuleTest instead

@staabm
Copy link
Contributor

staabm commented Feb 16, 2026

@phpstan-bot rebase branch and resolve the remaining unresolved comments

@phpstan-bot
Copy link
Collaborator Author

phpstan-bot commented Feb 16, 2026

Claude finished @staabm's task in 5m 24s —— View job


Addressing review comments and rebase

  • Gather context — read current code and review comments
  • Remove tests/PHPStan/Analyser/nsrt/bug-14129.php — already covered by set-constant-union-offset-on-constant-array.php
  • Move tests/PHPStan/Analyser/nsrt/bug-7978.php to CallMethodsRuleTest — created tests/PHPStan/Rules/Methods/data/bug-7978.php with a method call test and added testBug7978() to CallMethodsRuleTest
  • Add de-duplication for overlapping integer ranges in resolveFiniteScalarKeyTypes() — added $seen tracking to skip duplicate values from overlapping ranges
  • Add test for overlapping ranges (int<0,3>|int<2,4>) — added doOverlappingRanges test with 5 unique values (within CHUNK_FINITE_TYPES_LIMIT)
  • Push changes
  • Rebase branch onto origin/2.1.xunable to perform: git rebase is not available in my sandbox environment. The branch is currently 5 commits behind origin/2.1.x. This will need to be done manually.

All code changes have been pushed in commit 4277198.

View job | Branch

- 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>
Comment on lines +749 to +804
/**
* @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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* @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;
}

@staabm
Copy link
Contributor

staabm commented Feb 16, 2026

moving to a new branch in which I can commit: #4952

@staabm staabm closed this Feb 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants