Propagate array dim fetch narrowings to parent variable in BooleanAnd falsey and BooleanOr truthy intersectWith#5593
Conversation
…d` falsey and `BooleanOr` truthy `intersectWith` - Add `propagateArrayDimFetchNarrowingsToParent()` to `TypeSpecifier` that enriches normalized `SpecifiedTypes` by computing a parent array variable's narrowed type from its child `ArrayDimFetch` sureType using `HasOffsetValueType`, mirroring what `MutatingScope::specifyExpressionType` already does when applying narrowings - Call the new method in the `BooleanAnd` falsey branch and the `BooleanOr` truthy branch before `intersectWith`, so that both sides have a sureType for the parent variable and the intersection correctly produces the union - Skip propagation for nested `ArrayDimFetch` parents to avoid over-narrowing in cases where the intermediate offset may not exist - The fix handles all type-check functions (`is_string`, `is_int`, `is_array`, etc.) and `instanceof` uniformly since they all go through the same conditional return type / type specifier machinery
VincentLanglet
left a comment
There was a problem hiding this comment.
Would it make more sens to modify TypeSpecifier::createForExpr instead in order to add a special case for $expr instanceof ArrayDimFetch ?
|
All tests pass and static analysis is clean. Now let me provide the response to the reviewer's question. There are no code changes needed since the reviewer's suggestion isn't architecturally feasible. Let me explain why in a clear way that can be posted to the PR. The reviewer's suggestion to modify Why The core issue is about when the actual narrowed type is available:
Moving it to The current placement — a post-normalization enrichment step right before Since there are no code changes to make (the existing implementation is correct and the reviewer's question is answered by this technical explanation), there's nothing to commit or push. The explanation above should be posted as a reply to the review comment. |
ac4225d to
fd0eb88
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
I refactored into createForExpr to handle ArrayDimFetch with the latest commit
Analyse the benefit and the drawback compare to your solution, and propose suggestions/improvements, without implementing them yet
|
Both produce the same result for non-constant arrays. The original approach's Looking back at the original code: it checks Actually, wait. Let me re-read The behavior is the same for both approaches with non-constant arrays, so this isn't a practical differentiator. Now I have a thorough understanding. The review asks for analysis only, not implementation. Let me write the analysis as the response to the PR. Since the review asks me to "analyse the benefit and drawback" and "propose suggestions/improvements, without implementing them yet" — this is a pure analysis task with no code changes needed. There's nothing to commit or push. Here's my analysis of the two approaches: Analysis:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
Try the Use HasOffsetValueType refinement instead of constant-array elimination suggestion, and add a dedicated test which
- would have fail with the current implementation
- which does not fail with your suggestion
|
@VincentLanglet Please try to replicate the problem outside of arrays and ArrayDimFetch, for example with nested property fetches. I'm still not sure why we need hardcoding for arrays and nothing else. |
Instead of removing entire constant array variants via sureNotTypes when
the type fully covers the offset value type, compute the narrowed offset
value type using TypeCombinator::remove (false context) or
TypeCombinator::intersect (true context) and rebuild the constant array
with the refined value.
This handles partial overlap cases that the previous approach could not:
e.g. array{hi: string|int} after `is_string($arr['hi'])` in false
context correctly narrows to array{hi: int}, whereas the old approach
left it unchanged because string does not fully cover string|int.
Also applies toArrayKey() to the dim type (matching MutatingScope's
pattern) and avoids instanceof ConstantStringType.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Here's a summary of what was implemented: Changed
Added partial overlap test ( Updated bug-7716 expectations: The new approach also correctly propagates comparison-based refinements (e.g., |
VincentLanglet
left a comment
There was a problem hiding this comment.
I feel like the issue is well defined with the example
/**
* @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test
*/
function foo(array $test): void {
if (isset($test['hi'])) {
if (is_string($test['hi'])) {
return;
}
}
dumpType($test['hi']);
}
/**
* @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test
*/
function foo2(array $test): void {
if (isset($test['hi']) && is_string($test['hi'])) {
return;
}
dumpType($test['hi']);
}
While the first one works correctly, the second doesn't. It does not seems to be related to array.
The conditional expression holder mechanism in BooleanAnd falsey and
BooleanOr truthy paths previously only created holders for Expr\Variable
nodes. This meant that when two non-variable expressions (e.g. property
fetches) were combined with &&, follow-up narrowing was lost:
if ($c->x instanceof Foo && $c->y instanceof Foo) { return; }
if ($c->x instanceof Foo) { /* $c->y was Foo|Baz, not Bar */ }
Extend processBooleanSureConditionalTypes and
processBooleanNotSureConditionalTypes to accept PropertyFetch,
ArrayDimFetch, and StaticPropertyFetch as conditions and targets.
Replace the Variable-name-based self-reference check with an
expression-string comparison that works for any expression type.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All passing. Here's a summary of what was done to address the review: The reviewer's concern: The Investigation findings:
Changes made:
The array-specific |
Summary
When using
isset($arr['key']) && is_string($arr['key'])with an early return, PHPStan failed to narrow the array type in the falsey branch, leading to false positives like "Cannot assign new offset tolist<int>|string". The same issue affected the logically equivalent!isset($arr['key']) || !is_string($arr['key'])pattern in the truthy branch.Root cause
In the falsey branch of
BooleanAnd(and truthy branch ofBooleanOr),TypeSpecifiercomputes narrowings for the left and right conditions independently and combines them viaSpecifiedTypes::intersectWith. The left side (e.g. falseyisset) produces a sureType for the parent variable$test, while the right side (e.g. falseyis_string) produces a sureType for the array dim fetch$test['hi']. SinceintersectWithonly keeps expressions present in both sides, both narrowings were dropped — different expression keys meant the intersection was empty.The conditional expression holder mechanism (
processBooleanSureConditionalTypes/processBooleanNotSureConditionalTypes) couldn't help either, because it only processesExpr\Variablenodes and skipsArrayDimFetch.Changes
TypeSpecifier::propagateArrayDimFetchNarrowingsToParent()that enriches normalizedSpecifiedTypesby computing a parent array variable's narrowed type from its childArrayDimFetchsureType usingHasOffsetValueType— mirroring the propagation thatMutatingScope::specifyExpressionTypealready does when narrowings are applied to a scopesrc/Analyser/TypeSpecifier.phpintersectWith:BooleanAndfalsey handling (line ~737)BooleanOrtruthy handling (line ~799)ArrayDimFetchto avoid over-narrowing in nested offset access casesAnalogous cases probed
BooleanOrtruthy (!isset() || !is_string()): was broken, now fixedis_int,is_array,is_float,is_bool: all work correctly (same code path)instanceof: works correctly (same code path)$arr[0]): works correctlyarray_key_exists: not fixed by this PR — its falsey narrowing produces differentSpecifiedTypes(includes anunsetOffsetsureType) that requires separate handlingArrayDimFetch($arr['a']['b']): pre-existing bug, not made worse by this PR (propagation is skipped for nested parents)PropertyFetch: not applicable (different narrowing mechanism)Test
Added
tests/PHPStan/Analyser/nsrt/bug-14566.phpwith five test functions covering:isset($test['hi']) && is_string($test['hi'])with early returnBooleanOrequivalent:!isset($test['hi']) || !is_string($test['hi'])is_intvariantis_arrayvariantinstanceofvariantFixes phpstan/phpstan#14566