Skip to content

Restrict list type preservation in IntersectionType::setOffsetValueType to offsets within list key range#5455

Open
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-mfl2dg7
Open

Restrict list type preservation in IntersectionType::setOffsetValueType to offsets within list key range#5455
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-mfl2dg7

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When assigning to a list<T> with an arbitrary int key (e.g., $list[$intKey] = value where $intKey has type int), the list type was incorrectly preserved. The list should degrade to array<int, T> because an arbitrary int offset can include negative values or values that create gaps in the sequential key structure.

Changes

  • src/Type/IntersectionType.php: Changed the condition in setOffsetValueType that re-adds AccessoryArrayListType after an offset assignment. Previously it checked $offsetType->toArrayKey()->isInteger()->yes() (any integer offset), now it checks $this->getIterableKeyType()->isSuperTypeOf($offsetType)->yes() (offset must be within the list's key type range, i.e., int<0, max>).
  • tests/PHPStan/Analyser/nsrt/bug-10089.php: Updated assertion that was relying on the buggy behavior. $matrix[$size - 1][8] = 3 where $size is int<min, 8> (could be negative) correctly degrades the list since int<min, 7> is not a subtype of int<0, max>.
  • tests/PHPStan/Analyser/nsrt/bug-14336.php: New regression test covering:
    • Arbitrary int key in loop → list degraded to array
    • Arbitrary int key outside loop → list degraded
    • Negative int key → list degraded
    • int<0, max> key with array value → list preserved (valid key range for nested modifications)
    • int<0, max> key with scalar value → list degraded (lines 987-994 only apply for array values)
    • Append ([]) → list preserved
    • Constant int 0 → list preserved
    • Nested assignment with foreach key → outer list preserved

Root cause

IntersectionType::setOffsetValueType had a guard condition that re-added AccessoryArrayListType when the original type was a list, the offset was any integer, and the iterable value type was an array. This was originally added to preserve list types during nested dim fetch operations (e.g., $list[$k]['key'] = value), but the condition $offsetType->toArrayKey()->isInteger()->yes() was too broad — it also fired for arbitrary int offsets like those from foreach ($intMap as $intKey => ...) where $intKey has type int (including negatives).

The fix tightens this to $this->getIterableKeyType()->isSuperTypeOf($offsetType)->yes(), which for lists checks that the offset is within int<0, max>. This correctly rejects int (includes negatives) while still accepting int<0, max> (the type of foreach keys when iterating a list).

Analogous constructs probed

  • IntersectionType::setExistingOffsetValueType: No similar pattern; delegates directly to member types. AccessoryArrayListType::setExistingOffsetValueType returns $this which is correct for known-existing offsets.
  • IntersectionType::unsetOffset: No list re-addition logic; delegates to member types.
  • UnionType::setOffsetValueType: No AccessoryArrayListType handling.
  • ConstantArrayType::setOffsetValueType: Uses ConstantArrayTypeBuilder which properly checks for gaps via internal $isList tracking. Already correct.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14336.php: Regression test for the reported bug (arbitrary int key in loop) plus 7 additional edge cases covering negative ints, int<0, max> offsets, nested assignments, and safe patterns (append, constant 0).
  • Updated tests/PHPStan/Analyser/nsrt/bug-10089.php to reflect corrected behavior.

Fixes phpstan/phpstan#14336

…ype` to offsets within list key range

- In `IntersectionType::setOffsetValueType`, change the condition for
  re-adding `AccessoryArrayListType` from checking if the offset is any
  integer (`$offsetType->toArrayKey()->isInteger()->yes()`) to checking
  if the offset is within the list's key type range
  (`$this->getIterableKeyType()->isSuperTypeOf($offsetType)->yes()`).
- This prevents list type from being incorrectly preserved when
  assigning with an arbitrary `int` key (which includes negative
  values and could create gaps), while still preserving list type for
  `int<0, max>` offsets (valid list key range) used in nested
  modifications like `$list[$k]['key'] = value`.
- Update bug-10089 test assertion: `$matrix[$size - 1][8] = 3` where
  `$size` is `int<min, 8>` correctly degrades the list since
  `int<min, 7>` is not within the `int<0, max>` key range.
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

the suggested change does not fix the reproducer of phpstan/phpstan#14336

… reset

When processing nested array dim fetch assignments like `$arr[$key][$int] = $value`
where the container was previously reset (`$arr[$key] = []`), the list type was
incorrectly preserved. This happened because `produceArrayDimFetchAssignValueToWrite`
used `setExistingOffsetValueType` (which preserves list structure) based on the
general type from the parent's type system, while the actual tracked container type
was an empty array where the offset couldn't exist.

The fix adds a check at container levels (inner dimensions of nested dim fetches)
to verify that the scope's actual tracked type of the container also supports the
offset before using `setExistingOffsetValueType`. This correctly falls through to
`setOffsetValueType` when the container was just reset to `[]`, which properly
degrades the list type.

This addresses the actual reproducer from phpstan/phpstan#14336 where the previous
IntersectionType::setOffsetValueType fix alone was insufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The fix has been pushed. Here's a summary of what was done:

Root Cause Analysis

The reviewer (staabm) was correct - the original IntersectionType::setOffsetValueType change didn't fix the actual reproducer from phpstan/phpstan#14336. The reproducer involved a nested dim fetch pattern:

$xsdFiles[$xmlNamespace] = [];  // Reset to empty array
foreach (...) {
    $xsdFiles[$xmlNamespace][$int] = $viewHelper;  // Nested dim fetch with arbitrary int
}
// $xsdFiles[$xmlNamespace] was wrongly kept as list<...>

The bug was in AssignHandler::produceArrayDimFetchAssignValueToWrite (not in IntersectionType). When processing nested dim fetch assignments, the method decides between setExistingOffsetValueType (preserves list structure) and setOffsetValueType (may degrade list). The decision used:

  1. $scope->hasExpressionType($container) - was the container tracked? Yes (from $arr[$key] = [])
  2. $offsetValueType->hasOffsetValueType($offset) - does the general type say offset might exist? Yes (list<...> has int offsets)

But the actual tracked type of the container was array{} (empty), where no offset exists. This mismatch caused setExistingOffsetValueType to be used, preserving the list incorrectly.

Fix

Added a check at container levels (inner dimensions of nested dim fetches) that also verifies the scope's actual tracked type supports the offset:

$isContainerLevel = $i + 1 < count($dimFetchStack);
// ...
&& (!$isContainerLevel || !$scope->getType($arrayDimFetch)->hasOffsetValueType($offsetType)->no())

This only applies at inner levels where $arrayDimFetch represents the container (not at the outermost level where it's the leaf expression). This distinction is important to avoid breaking single-level cases like array_key_exists($needle, $list); $list[$needle] = 37;.

The IntersectionType::setOffsetValueType change from the original PR is kept as a complementary fix for direct (non-nested) assignment cases.

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