Skip to content

Commit 313bae4

Browse files
ondrejmirtesclaude
andcommitted
Record try-path definedness as conditional on the catch variable being undefined
At a try/catch join where the catch variable is untracked on the non-catch path and certainly defined at the end of the catch body, its later definedness tells the joined paths apart. Record conditional expression holders with a certainty-No (and, for scopes where any variable can exist, a maybe-defined-null) condition on the catch variable, restoring the non-catch certainty and type of variables the join demoted to maybe-defined. To let such holders fire, the isset()-style falsey specification in MutatingScope::applySpecifiedTypes() now publishes the certainty change into the specified-expressions batch - rescuing the waiting holders around unsetExpression()'s invalidation, which would otherwise drop them at the very specification they wait for. A second catch clause merging into the join drops the holders via the conditional-expressions intersection, so multi-catch stays conservative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
1 parent b4b7c2e commit 313bae4

5 files changed

Lines changed: 144 additions & 1 deletion

File tree

‎src/Analyser/MutatingScope.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4092,12 +4092,37 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
40924092
$expr = $issetExpr->getExpr();
40934093

40944094
if ($typeSpecification['sure']) {
4095+
$innerExprString = $scope->getNodeKey($expr);
40954096
$scope = $scope->setExpressionCertaintyKeepingType(
40964097
$expr,
40974098
TrinaryLogic::createMaybe(),
40984099
);
4100+
$specifiedExpressions[$innerExprString] = ExpressionTypeHolder::createMaybe(
4101+
$expr,
4102+
$scope->expressionTypes[$innerExprString]->getType(),
4103+
);
40994104
} else {
4105+
$innerExprString = $scope->getNodeKey($expr);
4106+
// Holders conditioned on this expression being undefined (a
4107+
// certainty-No condition) wait for exactly the specification
4108+
// applied here, but unsetExpression()'s invalidation would drop
4109+
// them before the conditional-expressions matcher below could
4110+
// fire them - carve them out and re-add them afterwards.
4111+
$rescuedHolders = [];
4112+
foreach ($scope->conditionalExpressions as $targetExprString => $targetHolders) {
4113+
foreach ($targetHolders as $holderKey => $conditionalHolder) {
4114+
$conditionHolder = $conditionalHolder->getConditionExpressionTypeHolders()[$innerExprString] ?? null;
4115+
if ($conditionHolder === null || !$conditionHolder->getCertainty()->no()) {
4116+
continue;
4117+
}
4118+
$rescuedHolders[$targetExprString][$holderKey] = $conditionalHolder;
4119+
}
4120+
}
41004121
$scope = $scope->unsetExpression($expr);
4122+
foreach ($rescuedHolders as $targetExprString => $targetHolders) {
4123+
$scope = $scope->addConditionalExpressions((string) $targetExprString, $targetHolders);
4124+
}
4125+
$specifiedExpressions[$innerExprString] = new ExpressionTypeHolder($expr, new ErrorType(), TrinaryLogic::createNo());
41014126
}
41024127
$scopeIsWorkingCopy = false;
41034128

‎src/Analyser/StmtHandler/TryCatchHandler.php‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
use PhpParser\Node\Expr;
99
use PhpParser\Node\Stmt;
1010
use PhpParser\Node\Stmt\TryCatch;
11+
use PhpParser\Node\Expr\Variable;
12+
use PHPStan\Analyser\ConditionalExpressionHolder;
1113
use PHPStan\Analyser\ExpressionResultStorage;
14+
use PHPStan\Analyser\ExpressionTypeHolder;
1215
use PHPStan\Analyser\InternalStatementExitPoint;
1316
use PHPStan\Analyser\InternalStatementResult;
1417
use PHPStan\Analyser\InternalThrowPoint;
@@ -26,7 +29,10 @@
2629
use PHPStan\Node\Variable\VariableWrite;
2730
use PHPStan\Node\VariableAssignNode;
2831
use PHPStan\ShouldNotHappenException;
32+
use PHPStan\TrinaryLogic;
33+
use PHPStan\Type\ErrorType;
2934
use PHPStan\Type\NeverType;
35+
use PHPStan\Type\NullType;
3036
use PHPStan\Type\ObjectType;
3137
use PHPStan\Type\TypeCombinator;
3238
use Throwable;
@@ -239,7 +245,13 @@ public function processStmt(
239245
$catchScopeForFinally = $catchScopeResult->getScope();
240246
$catchFlows[] = [$originalCatchType, VariableFlow::sequence($catchNode->var !== null ? VariableFlowBuilder::targetWrite($catchNode->var, VariableWrite::KIND_CATCH, $catchScopeForFinally, $storage) : null, $catchScopeResult->getVariableFlow())];
241247

242-
$finalScope = $catchScopeResult->isAlwaysTerminating() ? $finalScope : $catchScopeResult->getScope()->mergeWith($finalScope);
248+
if (!$catchScopeResult->isAlwaysTerminating()) {
249+
$mergedScope = $catchScopeResult->getScope()->mergeWith($finalScope);
250+
if ($variableName !== null && $finalScope !== null) {
251+
$mergedScope = $this->addCatchVariableDefinednessConditionals($mergedScope, $finalScope, $catchScopeResult->getScope(), $variableName);
252+
}
253+
$finalScope = $mergedScope;
254+
}
243255
$alwaysTerminating = $alwaysTerminating && $catchScopeResult->isAlwaysTerminating();
244256
$hasYield = $hasYield || $catchScopeResult->hasYield();
245257
$catchThrowPoints = $catchScopeResult->getThrowPoints();
@@ -323,4 +335,70 @@ public function processStmt(
323335
return new InternalStatementResult($finalScope, hasYield: $hasYield, isAlwaysTerminating: $alwaysTerminating, exitPoints: $exitPoints, throwPoints: array_merge($throwPoints, $throwPointsForLater), impurePoints: $impurePoints, variableFlow: VariableFlow::tryCatch($branchScopeResult->getVariableFlow(), $catchFlows, $finallyFlow));
324336
}
325337

338+
/**
339+
* The catch variable's definedness after the try/catch tells the two joined
340+
* paths apart: when it is untracked on the non-catch path and certainly
341+
* defined at the end of the catch body, "the catch variable is undefined"
342+
* later implies the non-catch path ran. Record that as conditional
343+
* expression holders with a certainty-No condition on the catch variable,
344+
* restoring the non-catch certainty (and type) of variables the join
345+
* demoted to maybe-defined - e.g. `isset($e) || $var instanceof \DateTime`
346+
* evaluates `$var` only where `$e` is narrowed away.
347+
*/
348+
private function addCatchVariableDefinednessConditionals(
349+
MutatingScope $mergedScope,
350+
MutatingScope $nonCatchScope,
351+
MutatingScope $catchEndScope,
352+
string $variableName,
353+
): MutatingScope
354+
{
355+
$variableExprString = '$' . $variableName;
356+
if (isset($nonCatchScope->expressionTypes[$variableExprString])) {
357+
return $mergedScope;
358+
}
359+
360+
$catchVariableHolder = $catchEndScope->expressionTypes[$variableExprString] ?? null;
361+
if ($catchVariableHolder === null || !$catchVariableHolder->getCertainty()->yes()) {
362+
return $mergedScope;
363+
}
364+
365+
$conditions = [
366+
[
367+
$variableExprString => new ExpressionTypeHolder(new Variable($variableName), new ErrorType(), TrinaryLogic::createNo()),
368+
],
369+
];
370+
if ($catchVariableHolder->getType()->isNull()->no()) {
371+
// In a scope where any variable can exist (e.g. the top level), the
372+
// isset() machinery models "!isset($e)" on a maybe-defined variable as
373+
// "maybe defined, null when defined" instead of unsetting it. That
374+
// state excludes the catch path just the same - it guarantees a
375+
// defined, non-null catch variable.
376+
$conditions[] = [
377+
$variableExprString => ExpressionTypeHolder::createMaybe(new Variable($variableName), new NullType()),
378+
];
379+
}
380+
foreach ($nonCatchScope->expressionTypes as $exprString => $holder) {
381+
if (!$holder->getCertainty()->yes()) {
382+
continue;
383+
}
384+
$expr = $holder->getExpr();
385+
if (!$expr instanceof Variable || !is_string($expr->name)) {
386+
continue;
387+
}
388+
$mergedHolder = $mergedScope->expressionTypes[$exprString] ?? null;
389+
if ($mergedHolder === null || !$mergedHolder->getCertainty()->maybe()) {
390+
continue;
391+
}
392+
393+
$conditionalHolders = [];
394+
foreach ($conditions as $condition) {
395+
$conditionalHolder = new ConditionalExpressionHolder($condition, $holder);
396+
$conditionalHolders[$conditionalHolder->getKey()] = $conditionalHolder;
397+
}
398+
$mergedScope = $mergedScope->addConditionalExpressions((string) $exprString, $conditionalHolders);
399+
}
400+
401+
return $mergedScope;
402+
}
403+
326404
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug6608;
4+
5+
use PHPStan\TrinaryLogic;
6+
use function PHPStan\Testing\assertType;
7+
use function PHPStan\Testing\assertVariableCertainty;
8+
9+
function (string $s): void {
10+
try {
11+
$var = new \DateTime($s);
12+
} catch (\Throwable $e) {}
13+
14+
if (isset($e) || $var instanceof \DateTime) {
15+
}
16+
17+
if (!isset($e)) {
18+
assertVariableCertainty(TrinaryLogic::createYes(), $var);
19+
assertType('DateTime', $var);
20+
} else {
21+
assertVariableCertainty(TrinaryLogic::createMaybe(), $var);
22+
}
23+
};

‎tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,4 +1777,13 @@ public function testBug11109(): void
17771777
$this->analyse([__DIR__ . '/data/bug-11109.php'], []);
17781778
}
17791779

1780+
public function testBug6608(): void
1781+
{
1782+
$this->cliArgumentsVariablesRegistered = true;
1783+
$this->polluteScopeWithLoopInitialAssignments = false;
1784+
$this->checkMaybeUndefinedVariables = true;
1785+
$this->polluteScopeWithAlwaysIterableForeach = true;
1786+
$this->analyse([__DIR__ . '/data/bug-6608.php'], []);
1787+
}
1788+
17801789
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php declare(strict_types = 1);
2+
3+
try {
4+
$var = new \DateTime('nope');
5+
} catch (\Throwable $e) {}
6+
7+
if (isset($e) || $var instanceof \DateTime) {
8+
}

0 commit comments

Comments
 (0)