Skip to content

Fix phpstan/phpstan#14308: Offset 0 should not be optional on non empty list#5236

Merged
VincentLanglet merged 10 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-uy1xlle
Mar 17, 2026
Merged

Fix phpstan/phpstan#14308: Offset 0 should not be optional on non empty list#5236
VincentLanglet merged 10 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-uy1xlle

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

False positive "Offset 0 might not exist on non-empty-list{0?: ...}" was reported when accessing offset 0 on a non-empty list with all optional keys in a constant array shape. The type system correctly knew offset 0 must exist (via IntersectionType::hasOffsetValueType()), but the rule's "report maybes" logic bypassed this by flattening the type into individual array shapes, losing the non-empty constraint.

Changes

  • Added early return in src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php when $type->hasOffsetValueType($dimType)->yes() — if the full type confirms the offset exists, skip the "maybe" reporting entirely
  • Added rule regression test in tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php (testBug14308)
  • Added test data in tests/PHPStan/Rules/Arrays/data/bug-14308.php
  • Added NSRT test in tests/PHPStan/Analyser/nsrt/bug-14308.php

Root cause

NonexistentOffsetInArrayDimFetchCheck::check() calls TypeUtils::flattenTypes() to decompose types into individual constant array shapes for the "report maybes" logic. For an IntersectionType containing ConstantArrayType (with all optional keys) and NonEmptyArrayType, flattenTypes() extracts the constant arrays via getConstantArrays() and getAllArrays(), but the resulting shapes lose the NonEmptyArrayType constraint. This means the empty array shape is included, which doesn't have offset 0, triggering a false "might not exist" report.

The IntersectionType::hasOffsetValueType() method already correctly handles this case — it checks if the type is a non-empty list and returns yes for offset 0. The fix adds an early return to check the full type before entering the flattening logic.

Test

The regression test reproduces the exact scenario from the issue: array_keys(array_filter([...])) followed by an empty-array check, then accessing offset 0. The rule test expects no errors (false positive eliminated).

Fixes phpstan/phpstan#14308

- Added early return in NonexistentOffsetInArrayDimFetchCheck when the full type confirms the offset exists
- The bug occurred because TypeUtils::flattenTypes() decomposes IntersectionType into individual ConstantArrayType shapes, losing the NonEmptyArrayType constraint
- IntersectionType::hasOffsetValueType() correctly returns "yes" for offset 0 on a non-empty list, but the "reportMaybes" logic bypassed this by checking flattened types
- New regression tests in rule test and NSRT

Closes phpstan/phpstan#14308
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-uy1xlle branch from 0a214d6 to 34cda84 Compare March 17, 2026 11:12
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-uy1xlle branch from 1b45d57 to cca316e Compare March 17, 2026 13:54
@VincentLanglet VincentLanglet requested a review from staabm March 17, 2026 15:08
@phpstan-bot
Copy link
Collaborator Author

Both tests and static analysis passed. The PR is already in good shape — the reviewer's earlier suggestion to extract the hasOffsetValueType result into a variable was already applied in the current code. The review is APPROVED with no outstanding changes needed.

Since there are no code changes to make and the working tree is clean, there's nothing to commit or push.

Comment on lines -407 to -413
yield [
new IntersectionType([
$listArrayWithAllOptionalKeys,
new NonEmptyArrayType(),
]),
'non-empty-list{0?: string, 1?: string, 2?: string, 3?: string}',
];
Copy link
Contributor

Choose a reason for hiding this comment

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

why the need to drop a test?

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 was testing that the type is transformed into the description and the description is transformed into the type.

But it's not true anymore.

While

new IntersectionType([
				$listArrayWithAllOptionalKeys,
				new NonEmptyArrayType(),
			])

is transformed into 'list{0: string, 1?: string, 2?: string, 3?: string}', the opposite is not true.

This has to be a TypeCombinator test instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

All review comments addressed and pushed. Here's a summary of the changes:

  1. TypeCombinator.php - Moved instanceof checks before method calls (isList(), isOptionalKey()) in both condition blocks for short-circuit performance. Also fixed a bug in the second block where $types[$i]->isList() should have been $types[$j]->isList() (since $types[$j] is the ConstantArrayType in that branch, not $types[$i]).

  2. TypeToPhpDocNodeTest.php - Moved the test to dataToPhpDocNodeWithoutCheckingEquals instead of deleting it. The test was removed because TypeCombinator::intersect() now simplifies the intersection into a plain ConstantArrayType, so the round-trip assertion ($type->equals($parsedType)) fails — parsing 'non-empty-list{0?: string, ...}' back produces list{0: string, ...}. The toPhpDocNode() output itself is still correct, so the test belongs in the "without checking equals" provider.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Already handled — all 11588 tests passed and the commit has been pushed.

VincentLanglet and others added 3 commits March 17, 2026 16:24
…Combinator and restore toPhpDocNode test

- Move instanceof checks before isList()/isOptionalKey() calls for short-circuit performance
- Fix bug: second condition block was checking $types[$i]->isList() instead of $types[$j]->isList()
- Move toPhpDocNode test to dataToPhpDocNodeWithoutCheckingEquals since TypeCombinator::intersect now simplifies the type, breaking round-trip equality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ith NonEmptyArrayType

Move the test case removed from TypeToPhpDocNodeTest into TypeCombinatorTest,
since the intersection is now simplified by TypeCombinator rather than remaining
as an IntersectionType.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ith NonEmptyArrayType

Converts the dropped TypeToPhpDocNodeTest into a TypeCombinator test as
suggested by reviewer. The intersection of a list with all optional keys
and NonEmptyArrayType should make offset 0 required.

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

The working tree is clean and all previous review comments have been addressed. The review from staabm is an approval with no new code changes requested. There's nothing to do here — the PR is approved and ready to merge.

@VincentLanglet VincentLanglet merged commit 5c0e37f into phpstan:2.1.x Mar 17, 2026
424 of 426 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-uy1xlle branch March 17, 2026 15:53
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.

3 participants