Do not match conditional expression guards when ConstantArrayType has extra keys#5640
Do not match conditional expression guards when ConstantArrayType has extra keys#5640phpstan-bot wants to merge 6 commits into
Conversation
… extra keys In filterBySpecifiedTypes Pass 2 (supertype match), isSuperTypeOf with structural subtyping for ConstantArrayType allowed a current type with extra keys to satisfy a guard expecting fewer keys. This caused incorrect type narrowing through a chain of conditional expressions. Closes phpstan/phpstan#14595
There was a problem hiding this comment.
This is not related to extra key because this can be reproduced with
/**
* @param array<mixed> $data
* @param array{
* multiple: 0|1|2
* } $options
*/
function formulaire_edition(array $data, array $options, bool $total): void {
$instructions = [ ];
$instructions[] = "foo";
if ($options['multiple'] != 1 || $total) {
$instructions[] = "bar";
}
\PHPStan\dumpType($options['multiple']); // 0|1|2 (ok)
if (!$total) {
$instructions[] = "baz";
}
\PHPStan\dumpType($options['multiple']); // 0|1|2 (ok)
if (!$total) {
$instructions[] = "qux";
}
\PHPStan\dumpType($options['multiple']); // should be: 0|1|2
}
But notice
/**
* @param array<mixed> $data
* @param 0|1|2 $options
*/
function formulaire_edition(array $data, int $options, bool $total): void {
$instructions = [ ];
$instructions[] = "foo";
if ($options != 1 || $total) {
$instructions[] = "bar";
}
\PHPStan\dumpType($options); // 0|1|2 (ok)
if (!$total) {
$instructions[] = "baz";
}
\PHPStan\dumpType($options); // 0|1|2 (ok)
if (!$total) {
$instructions[] = "qux";
}
\PHPStan\dumpType($options); // should be: 0|1|2
}
works fine
The bug also reproduces when `total` is a separate `bool` parameter instead of a key in the `$options` array. The existing fix handles this case because the extra-keys check applies to `$instructions` (the guard condition variable that grows), not to `$options`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's a summary of the investigation and response to the review: The reviewer's observation is correct — the bug reproduces with their variant where The reason: the "extra keys" check in the fix applies to I verified this by:
I added the reviewer's variant as an additional regression test to cover this scenario explicitly. |
VincentLanglet
left a comment
There was a problem hiding this comment.
I don't fully understand this fix. Can't the issue happen in other case than constantArray ? Can't the fix be simpler or more generic ?
And what if hasOffsetValueType return Maybe ?
Instead of checking individual keys with hasOffsetValueType(), compare key counts. Since isSuperTypeOf() already verified all guard keys exist in the current type, any extra keys manifest as a higher key count. This addresses the reviewer's concern about hasOffsetValueType() returning Maybe and simplifies the logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's the summary for the reviewer's questions: 1. "Can't the issue happen in other cases than ConstantArray?" No. The issue is specific to 2. "Can't the fix be simpler or more generic?" Simplified the fix to use key count comparison instead of per-key 3. "What if hasOffsetValueType returns Maybe?" Eliminated this concern entirely by switching to key count comparison, which doesn't use All 12,049 tests pass, |
| $hasExtraKeys = false; | ||
| foreach ($specifiedExpressions[$holderExprString]->getType()->getConstantArrays() as $currentConstantArray) { | ||
| if (count($currentConstantArray->getKeyTypes()) > $guardKeyCount) { | ||
| $hasExtraKeys = true; |
There was a problem hiding this comment.
hasExtraKeys var is unneeded, you could just continue 3
There was a problem hiding this comment.
Both review comments addressed:
-
Removed
$hasExtraKeysflag — replaced withcontinue 3directly from the inner loop, eliminating the flag variable and the separateifcheck. -
Support multiple guard constant arrays — changed
count($guardConstantArrays) !== 1tocount($guardConstantArrays) === 0(only skip when there are no constant arrays), and compare current key counts againstmax()across all guard constant arrays instead of only handling the single-array case.
All 12,049 tests pass and make phpstan reports 0 errors.
| } | ||
|
|
||
| $guardConstantArrays = $conditionalTypeHolder->getType()->getConstantArrays(); | ||
| if (count($guardConstantArrays) !== 1) { |
There was a problem hiding this comment.
Why we only support single constant array ?
Address review feedback: - Use `continue 3` directly instead of a flag variable - Support multiple guard constant arrays by comparing against the max key count, instead of skipping when count !== 1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… key counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VincentLanglet
left a comment
There was a problem hiding this comment.
Function max() should not be referenced via a fallback global name, but via a use statement.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Added |
Closes phpstan/phpstan#14595
Summary
When a function had multiple
ifblocks that conditionally appended to an array, PHPStan could incorrectly narrow an unrelated array offset type. The issue required: (1) array offsets accessed from a typed array parameter, (2) conditional logic creating type guards, and (3) multipleifblocks that grew a local array variable.Root cause
In
MutatingScope::filterBySpecifiedTypesPass 2 (supertype match), the guard-matching logic usedisSuperTypeOfto check whether the current type of a variable satisfies a conditional expression guard. ForConstantArrayType,isSuperTypeOfuses structural subtyping —array{0: 'foo'}->isSuperTypeOf(array{0: 'foo', 1: 'bar'})returnsYesbecause the subtype has all required keys. This is correct for method signatures and type acceptance, but too permissive for guard matching: a 2-element array should not match a guard expecting a 1-element array.This caused a chain reaction: narrowing
$optionsto a specific shape fired a conditional expression that narrowed$instructions, and then a stale guard on$instructions(with fewer keys than the actual type) incorrectly fired, narrowing$options['multiple']from0|1|2to1.Fix
After the existing
isSuperTypeOfcheck in Pass 2, add a targeted check forConstantArrayTypeguards: verify that the current type's constant arrays do not have keys absent from the guard. If any current array has a key that the guard array lacks, skip the conditional expression.This preserves structural subtyping for all other contexts (
accepts, method signatures, generalisSuperTypeOfusage) while preventing incorrect guard matching.Test plan
tests/PHPStan/Analyser/nsrt/bug-14595.phpreproduces the exact issue from the bug reportmake phpstanpasses (0 errors)make cs-fixapplied (early-exit style)