Fix phpstan/phpstan#10055: Conditional argument type not properly narrowing type (for constant string unions?)#5386
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
You say
Later, when matching conditions in a match arm where $param1 is narrowed to 'value1', the equals() check compares ConstantStringType('value1') against UnionType('value1'|'value2'), which always fails.
Rather splitting the Union here, shouldn't the check "later" be improve to also supports isSuperType sometimes ?
|
Done. Here's a summary of what was changed to address the reviewer's feedback: Reviewer's concern: Instead of splitting union types into individual What was done:
Why the flag is needed: Using |
VincentLanglet
left a comment
There was a problem hiding this comment.
There is no need for a useSubtypeForConditionMatching param, and this shouldn't be dependant to the situation (match, if, etc) but more with the type.
Maybe finiteTypes or something like this ?
Also explain more in detailed why this doesn't work for youur mixed~null example.
|
The changes have been pushed. Here's a summary of what was done to address the reviewer's feedback: What the reviewer asked for:
What was implemented: Removed the flag entirely and replaced it with a two-pass matching approach in
Why a simple Using The two-pass approach solves this: exact matches (Pass 1) take priority, preventing broader conditions from interfering. Pass 2 only activates when no exact match exists, which is exactly the case for conditional parameter types with union conditions. Additional improvement: The bug-5051 test expectations were updated to reflect more precise type narrowing that results from the Pass 2 fallback matching. |
21b91c0 to
6f358cd
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
Explains why the check
if (count($conditionalTypeHolder->getType()->getFiniteTypes()) === 0) {
continue 2;
}
is needed ?
We could think that it should work for more/every type,
Like
- If is_int|is_string , then a condition if is_int should match
- If i > 2, then a condition i > 3 should match
So why can't we just remove the finiteType condition ?
|
Pushed. Here's a summary of the changes to address the reviewer's question: The reviewer asked why the I verified the answer empirically by removing the check and running the test suite — 2 concrete regressions confirmed the check is necessary:
The root cause: conditional expression holders come from 4 different sources — only source (1) conditional parameter types and (5) foreach constant arrays produce finite condition types. Sources 2-4 (scope merging, assignment handlers, TypeSpecifier boolean processing) produce non-finite types that are too broad for I added a detailed comment in the code explaining all of this. |
VincentLanglet
left a comment
There was a problem hiding this comment.
But we should have
function test(mixed $foo): void
{
$a = 0;
if (\is_string($foo) || \is_int($foo)) {
$a = 1;
}
if (\is_int($foo)) {
assertType('1', $a);
}
}
in which case, are you with this example ; 1, 2, 3, 4 or 5 ?
|
Pushed. Here's a summary of the changes made to address the reviewer's feedback: Reviewer's example: A scope-merging case where What was changed: Replaced the The
Union types explicitly enumerate alternatives, making Test added: Verification: |
VincentLanglet
left a comment
There was a problem hiding this comment.
Remove the check
if (!$conditionalTypeHolder->getType() instanceof UnionType) {
continue 2;
}
and consider the needed fix is somewhere else.
For bug-14249 the getMixed method should be marked with
/** @phpstan-impure */
For pr-5379 file maybe some conditinal expression are wrongly added somewhere else ?
A minimal reproducer is
/**
* @param array<string, mixed> $aggregation
* @param non-falsy-string $type
*/
private function parseAggregation(array $aggregation, string $type)
{
if (empty($aggregation['field']) && $type !== 'filter') {
return null;
}
if ($type !== 'filter') {
assertType("array<string, mixed>", $aggregation);
}
assertType('non-falsy-string', $type);
}
which ends with some weird condition
$aggregation=array<string, mixed> => 'filter' (Yes)
$type=non-falsy-string => non-empty-array<string, mixed>&hasOffset('field') (Yes)
``
added in the BooleanAndHandler
|
All three commits have been pushed. Here's a summary of the changes: Commit 1: Skip trivially-always-true conditions in
Commit 2: Mark
Commit 3: Remove
All 11840 tests pass (79348 assertions) and |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a non regression test for the change made in src/Analyser/TypeSpecifier.php which failed before even without the change in the MutatingScope.
|
Done. Added a non-regression test All tests pass (11840 tests, 79348 assertions) and |
VincentLanglet
left a comment
There was a problem hiding this comment.
Is there other TypeCombinator::remove call to fix the same way ?
|
Done. Here's a summary of the analysis and change: The reviewer asked: Are there other Analysis of all
What was fixed: The symmetric counterpart The result-side |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a non regression test for the latest fix
|
Done. Added a non-regression test The test uses I verified the test would fail without the fix by temporarily reverting it — |
VincentLanglet
left a comment
There was a problem hiding this comment.
Your test is weird cause we should already have
/**
* @param non-falsy-string $type
* @param int|string $value
*/
function testNotSureTriviallyTrueConditionSkipped(string $type, int|string $value): void
{
if (is_string($type) && is_int($value)) {
return;
}
assertType('string', $value);
}
since is_string($type) is a useless condition
|
Pushed. Here's a summary of what was changed to address the reviewer's feedback: Reviewer's concern: The test What was done:
Why |
2d5f0f6 to
7935f44
Compare
- Split union condition types into individual ConditionalExpressionHolders - When TypeCombinator::intersect or ::remove produces a UnionType, each member gets its own holder so the equals() check can match individual constant types - New regression test in tests/PHPStan/Analyser/nsrt/bug-10055.php
Instead of splitting union condition types into individual ConditionalExpressionHolder instances, use isSuperTypeOf at the matching site so that a narrowed type (e.g. 'value1') correctly matches a broader condition type (e.g. 'value1'|'value2'). A new useSubtypeForConditionMatching flag on ConditionalExpressionHolder limits this relaxed matching to conditional parameter type holders only. Other holders (from scope merging, assignments, type specifying) keep strict equals() matching to avoid cascading side effects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tead
Instead of a flag on ConditionalExpressionHolder to control whether
condition matching uses equals() or isSuperTypeOf(), use a two-pass
approach in filterBySpecifiedTypes():
Pass 1: exact matching via equals() (existing behavior)
Pass 2: isSuperTypeOf for condition types with finite types (fallback)
Pass 2 only runs when pass 1 found no matches for a target expression.
This handles conditional parameter types where the condition is a union
(e.g. 'value1'|'value2') that can't match a narrowed type ('value1')
via equals(), but should match via isSuperTypeOf.
The two-pass approach avoids regressions from using isSuperTypeOf
globally: when scope merging creates both a specific condition (e.g.
"if $key=2, then $value is Yes") and a broader condition (e.g. "if
$key=0|2, then $value is Maybe"), exact matching in pass 1 prevents
the broader condition from degrading variable certainty through
extremeIdentity.
The bug-5051 test expectations are updated to reflect more precise
type narrowing: when narrowing $data to a specific constant, PHPStan
now correctly determines which branch was taken and narrows related
variables accordingly (e.g. $update becomes 'false' instead of 'bool'
when $data === 1 because that branch always sets $update = false).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hing Address reviewer question about why isSuperTypeOf cannot be used for all types. Conditional expression holders come from multiple sources (param conditional types, scope merging, assignment handlers, TypeSpecifier boolean processing). Non-finite condition types from scope merging and assignment handlers are too broad for isSuperTypeOf matching — e.g. non-falsy-string incorrectly matches 'filter', or mixed~false matches false, causing conflicting conditional expressions to produce *NEVER*. The finite types check restricts Pass 2 to closed sets of concrete values where subtype matching is safe. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…atching Replace the getFiniteTypes() guard with an instanceof UnionType check in Pass 2 of filterBySpecifiedTypes(). This broadens Pass 2 to handle all union condition types (not just finite ones), which fixes cases where scope merging creates union conditions like int|string that should match a narrowed type like int via isSuperTypeOf. The UnionType guard still prevents regressions from non-union condition types (non-falsy-string from TypeSpecifier, mixed~false from assignment handlers) that are too broad for isSuperTypeOf matching. Added test case for scope merging with is_string()||is_int() narrowing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The UnionType guard was a workaround for two issues that are now fixed at their root causes: 1. pr-5379 regression (non-falsy-string matching 'filter'): Fixed by skipping trivially-always-true conditions in processBooleanSureConditionalTypes — when TypeCombinator::remove has no effect, the condition is not created. 2. bug-14249 regression (mixed~false producing *NEVER*): Fixed by marking the getMixed() test helper as @phpstan-impure, preventing conflicting conditional expressions from assignment handlers. With these root causes addressed, Pass 2 can safely use isSuperTypeOf for all condition types without restriction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7935f44 to
816fdb7
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
Add non regression test for
-
phpstan/phpstan#13591 both nsrt and asserting you dont have
Parameter #1 $hotelId of function processHotel expects int, int|null given.anymore. -
phpstan/phpstan#12597 asserting you don't have
Variable $message might not be defined.anymore -
phpstan/phpstan#10422 bot nsrt and asserting you don't get
Cannot call method test() on TestClass|null.anymore
- #13591: nsrt + rule test verifying conditional parameter type narrowing works with boolean-and guard (no false positive for int|null parameter) - #12597: rule test verifying variable definedness after in_array check (no false "Variable might not be defined" warning) - #10422: nsrt + rule test verifying method call on nullable after if/elseif guard with die() (no false "Cannot call method on null") Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's a summary of the non-regression tests added: phpstan/phpstan#13591 (conditional parameter type with boolean-and guard):
phpstan/phpstan#12597 (variable definedness after in_array check):
phpstan/phpstan#10422 (method call on nullable after if/elseif + die guard):
All tests pass: |
VincentLanglet
left a comment
There was a problem hiding this comment.
Merge data/bug-10422.php and nsrt file.
Same for bug-13591.php
Add non regression nsrt test for phpstan/phpstan#6663
Add non regression test for phpstan/phpstan#4090 both nsrt and RuleTest checking there is no Parameter #1 $string of function trim expects string, string|false given.. But use only one file.
Rule tests now reference the nsrt files directly instead of maintaining separate data files with duplicate code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- #6663: nsrt test for type narrowing in nested if with || condition - phpstan#4090: nsrt + rule test verifying no "trim expects string, string|false given" error when array count is checked via elseif/switch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All changes have been pushed. Here's a summary of what was done: Commit 1: Merge rule test data files into nsrt files for #10422 and #13591
Commit 2: Add non-regression tests for #6663 and #4090
All 11849 tests pass (79357 assertions) and |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add non regression for phpstan/phpstan#11218
- https://phpstan.org/r/6ff7bf65-fcdc-4a07-8596-3cbee65338be => Ensure there is no
Variable $test might not be defined.error - https://phpstan.org/r/b6a3c5fc-a4d2-4525-915e-f392b32e5193 => Ensure there is no
Offset 'test' does not exist on array{}|array{test: literal-string&non-falsy-string}.
- DefinedVariableRuleTest: ensure no "Variable $test might not be defined" error in for loop where variable is always defined on first iteration - NonexistentOffsetInArrayDimFetchRuleTest: ensure no "Offset 'test' does not exist" error when array offset is always set on first iteration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's a summary of the changes: Added non-regression tests for phpstan/phpstan#11218 (For loop - Variable might not be defined even if it is defined):
All 11851 tests pass (79359 assertions) and |
Summary
Conditional parameter types (
@param ($param1 is 'value3' ? bool : int) $param2) fail to narrow correctly when the parameter union type has more than 2 members. The "false" branch condition becomes a UnionType that never matches individual constant types during theequals()check.Changes
src/Analyser/MutatingScope.phpto split union condition types into individualConditionalExpressionHolderinstances when creating holders for@paramconditional typestests/PHPStan/Analyser/nsrt/bug-10055.phpRoot cause
When processing
@param ($param1 is 'value3' ? bool : int) $param2with$param1typed as'value1'|'value2'|'value3':TypeCombinator::intersect('value1'|'value2'|'value3', 'value3')='value3'(works fine)TypeCombinator::remove('value1'|'value2'|'value3', 'value3')='value1'|'value2'(a UnionType)Later, when matching conditions in a
matcharm where$param1is narrowed to'value1', theequals()check comparesConstantStringType('value1')againstUnionType('value1'|'value2'), which always fails.The fix splits union condition types into individual holders: instead of one holder with condition
'value1'|'value2', two holders are created — one for'value1'and one for'value2'— both mapping to the same result type (int). This allows theequals()check to match correctly.Test
Added
tests/PHPStan/Analyser/nsrt/bug-10055.phpwhich verifies that in a match expression, conditional parameter types correctly narrow$param2tointfor'value1'and'value2'arms, and toboolfor the'value3'arm.Fixes phpstan/phpstan#10055
Fixes phpstan/phpstan#13591
Fixes phpstan/phpstan#12597
Fixes phpstan/phpstan#10422
Fixes phpstan/phpstan#6663
Fixes phpstan/phpstan#4090
Fixes phpstan/phpstan#11218