Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -3238,6 +3238,9 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self

// Pass 2: Supertype match. Only runs when Pass 1 found no exact match for this expression.
foreach ($conditionalExpressions as $conditionalExpression) {
if ($conditionalExpression->getTypeHolder()->getCertainty()->no()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need a similar check for the "Pass 1" foreach loop a few lines above?
if so, add a test-case which fails without a similar fix for "Pass 1".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed. Here's a summary of the review response:

staabm's question: Does Pass 1 need a similar No-certainty check?

Answer: No. The key difference is the matching strategy:

  • Pass 1 uses exact matching (equals()). If the specified type exactly equals the No branch's condition type, the scope genuinely corresponds to the "variable is undefined" state. Exact matching can't produce the asymmetry that causes the bug — it's impossible for a type to simultaneously equal two different condition types.

  • Pass 2 uses supertype matching (isSuperTypeOf()), which is inherently asymmetric. A broader condition type (the "undefined" branch, with fewer constraints like array&hasOffsetValue('a', 'b')) is more likely to be a supertype of a narrowed type than a narrower condition type (the "defined" branch, like array&hasOffsetValue('a', 'b')&hasOffsetValue('c', ...)). This asymmetry causes only the No-certainty branch to match, incorrectly resolving the variable as never defined.

I added a comment explaining this reasoning directly in the code at the Pass 2 check, so future readers understand why the guard is only needed there. All tests (11,853) and make phpstan pass.

continue;
}
foreach ($conditionalExpression->getConditionExpressionTypeHolders() as $holderExprString => $conditionalTypeHolder) {
if (
!array_key_exists($holderExprString, $specifiedExpressions)
Expand Down
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ public function testBug4846(): void
]);
}

public function testBug14458(): void
{
$this->analyse([__DIR__ . '/data/bug-14458.php'], []);
}

public function testBug14393(): void
{
$this->analyse([__DIR__ . '/data/bug-14393.php'], [
Expand Down
50 changes: 50 additions & 0 deletions tests/PHPStan/Rules/Variables/data/bug-14458.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php declare(strict_types = 1);

namespace Bug14458;

use DateTime;

class Foo
{

/**
* @param array<mixed> $payload
* @return array<mixed>
*/
public function doFoo(array $payload): array
{
if ($payload['a'] !== 'b') {
throw new \Exception();
}

if (isset($payload['c'])) {
$c = array_values(array_map(static fn (array $cd) => $cd[0], $payload['c']));
}

$convertedPriceWithVat = null;
if (array_key_exists('cpwv', $payload)) {
$convertedAmount = (float) $payload['cpwv']['awv'];

}

return [
$payload['cf'],
$payload['n'],
$payload['d'] ?? null,
$payload['mb'] ?? null,
$payload['cr'],
new DateTime($payload['p']),
$payload['pn'],
$payload['pd'] ?? null,
$payload['piu'] ?? null,
$convertedPriceWithVat,
$payload['vi'],
$payload['pi'],
$payload['user']['name'] ?? null,
$payload['user']['phone'] ?? null,
$payload['ac'] ?? null,
$c ?? null,
];
}

}
Loading