Skip to content

Commit f8c5ebc

Browse files
committed
Avoid dead catch false positive for inconsistently overridden trait methods
A trait's try/catch can be dead in the context of one class using the trait and alive in another, e.g. when it depends on whether an abstract method gets overridden without throwing. Apply the same ConstantConditionInTraitHelper mechanism already used for isset/empty/?? to CatchWithUnthrownExceptionRule, so disagreeing verdicts across classes using the trait suppress the error instead of reporting it. Closes phpstan/phpstan#10315
1 parent 4177ab7 commit f8c5ebc

11 files changed

Lines changed: 441 additions & 47 deletions

src/Analyser/StmtHandler/TryCatchHandler.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\Analyser\StatementContext;
1414
use PHPStan\Analyser\StmtHandler;
1515
use PHPStan\DependencyInjection\AutowiredService;
16+
use PHPStan\Node\CatchWithThrownExceptionInTraitNode;
1617
use PHPStan\Node\CatchWithUnthrownExceptionNode;
1718
use PHPStan\Node\Expr\TypeExpr;
1819
use PHPStan\Node\FinallyExitPointsNode;
@@ -169,6 +170,13 @@ public function processStmt(
169170
// emit error
170171
foreach ($matchingCatchTypes as $catchTypeIndex => $matched) {
171172
if ($matched) {
173+
// A trait's catch can be dead in the context of one class using the
174+
// trait and alive in the context of another, so the alive ones have
175+
// to be reported there as well for the disagreement to be noticed.
176+
if ($scope->isInTrait()) {
177+
$nodeScopeResolver->callNodeCallback($nodeCallback, new CatchWithThrownExceptionInTraitNode($catchNode, $originalCatchTypes[$catchTypeIndex]), $scope, $storage);
178+
}
179+
172180
continue;
173181
}
174182
$nodeScopeResolver->callNodeCallback($nodeCallback, new CatchWithUnthrownExceptionNode($catchNode, $catchTypes[$catchTypeIndex], $originalCatchTypes[$catchTypeIndex]), $scope, $storage);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Node;
4+
5+
use Override;
6+
use PhpParser\Node\Stmt\Catch_;
7+
use PhpParser\NodeAbstract;
8+
use PHPStan\Type\Type;
9+
10+
/**
11+
* A catch clause inside a trait whose caught type _is_ thrown in the try block.
12+
*
13+
* Emitted only in traits. A trait's catch can be dead in the context of one class using
14+
* the trait and alive in the context of another, so CatchWithUnthrownExceptionRule has to
15+
* learn about the alive ones as well to notice the disagreement. Dead catches keep being
16+
* reported through CatchWithUnthrownExceptionNode.
17+
*/
18+
final class CatchWithThrownExceptionInTraitNode extends NodeAbstract implements VirtualNode
19+
{
20+
21+
public function __construct(private Catch_ $originalNode, private Type $originalCaughtType)
22+
{
23+
parent::__construct($originalNode->getAttributes());
24+
}
25+
26+
public function getOriginalNode(): Catch_
27+
{
28+
return $this->originalNode;
29+
}
30+
31+
public function getOriginalCaughtType(): Type
32+
{
33+
return $this->originalCaughtType;
34+
}
35+
36+
#[Override]
37+
public function getType(): string
38+
{
39+
return 'PHPStan_Node_CatchWithThrownExceptionInTraitNode';
40+
}
41+
42+
/**
43+
* @return string[]
44+
*/
45+
#[Override]
46+
public function getSubNodeNames(): array
47+
{
48+
return [];
49+
}
50+
51+
}

src/Rules/Comparison/ConstantConditionInTraitHelper.php

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ public function emitNoError(
4040
Scope&NodeCallbackInvoker&CollectedDataEmitter $scope,
4141
Expr $expr,
4242
): void
43+
{
44+
$this->emitNoErrorForKey($ruleName, $scope, $this->exprString($expr));
45+
}
46+
47+
/**
48+
* @param class-string<Rule<covariant Node>> $ruleName
49+
*/
50+
public function emitError(
51+
string $ruleName,
52+
Scope&NodeCallbackInvoker&CollectedDataEmitter $scope,
53+
Expr $expr,
54+
bool $value,
55+
RuleError $ruleError,
56+
): void
57+
{
58+
$this->emitErrorForKey($ruleName, $scope, $expr, $this->exprString($expr), $value, $ruleError);
59+
}
60+
61+
/**
62+
* Like emitNoError(), but for callers that cannot key their check by a single Expr
63+
* (e.g. one Rule node covering several distinct checks at the same location).
64+
*
65+
* @param class-string<Rule<covariant Node>> $ruleName
66+
*/
67+
public function emitNoErrorForKey(
68+
string $ruleName,
69+
Scope&NodeCallbackInvoker&CollectedDataEmitter $scope,
70+
string $key,
71+
): void
4372
{
4473
if (!$scope->isInTrait()) {
4574
return;
@@ -48,18 +77,22 @@ public function emitNoError(
4877
$scope->emitCollectedData(ConstantConditionInTraitCollector::class, [
4978
$ruleName,
5079
$scope->getTraitReflection()->getName(),
51-
$this->exprString($expr),
80+
$key,
5281
null,
5382
]);
5483
}
5584

5685
/**
86+
* Like emitError(), but for callers that cannot key their check by a single Expr
87+
* (e.g. one Rule node covering several distinct checks at the same location).
88+
*
5789
* @param class-string<Rule<covariant Node>> $ruleName
5890
*/
59-
public function emitError(
91+
public function emitErrorForKey(
6092
string $ruleName,
6193
Scope&NodeCallbackInvoker&CollectedDataEmitter $scope,
62-
Expr $expr,
94+
Node $node,
95+
string $key,
6396
bool $value,
6497
RuleError $ruleError,
6598
): void
@@ -75,9 +108,9 @@ public function emitError(
75108
$scope->emitCollectedData(ConstantConditionInTraitCollector::class, [
76109
$ruleName,
77110
$scope->getTraitReflection()->getName(),
78-
$this->exprString($expr),
111+
$key,
79112
$value,
80-
$this->ruleErrorTransformer->transform($ruleError, $scope, [], $expr),
113+
$this->ruleErrorTransformer->transform($ruleError, $scope, [], $node),
81114
]);
82115
}
83116

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Exceptions;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\CollectedDataEmitter;
7+
use PHPStan\Analyser\NodeCallbackInvoker;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\DependencyInjection\RegisteredRule;
10+
use PHPStan\Node\CatchWithThrownExceptionInTraitNode;
11+
use PHPStan\Rules\Comparison\ConstantConditionInTraitHelper;
12+
use PHPStan\Rules\Rule;
13+
14+
/**
15+
* Records that a catch clause in a trait is alive in the context of the current class,
16+
* so that CatchWithUnthrownExceptionRule does not report it as dead based only on the
17+
* classes using the trait where it happens to be unreachable.
18+
*
19+
* @implements Rule<CatchWithThrownExceptionInTraitNode>
20+
*/
21+
#[RegisteredRule(level: 4)]
22+
final class CatchWithThrownExceptionInTraitRule implements Rule
23+
{
24+
25+
public function __construct(private ConstantConditionInTraitHelper $constantConditionInTraitHelper)
26+
{
27+
}
28+
29+
public function getNodeType(): string
30+
{
31+
return CatchWithThrownExceptionInTraitNode::class;
32+
}
33+
34+
public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array
35+
{
36+
$this->constantConditionInTraitHelper->emitNoErrorForKey(
37+
CatchWithUnthrownExceptionRule::class,
38+
$scope,
39+
DeadCatchInTraitKey::create($node->getOriginalNode(), $node->getOriginalCaughtType()),
40+
);
41+
42+
return [];
43+
}
44+
45+
}

src/Rules/Exceptions/CatchWithUnthrownExceptionRule.php

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
namespace PHPStan\Rules\Exceptions;
44

55
use PhpParser\Node;
6+
use PHPStan\Analyser\CollectedDataEmitter;
7+
use PHPStan\Analyser\NodeCallbackInvoker;
68
use PHPStan\Analyser\Scope;
79
use PHPStan\DependencyInjection\AutowiredParameter;
810
use PHPStan\DependencyInjection\RegisteredRule;
911
use PHPStan\Node\CatchWithUnthrownExceptionNode;
12+
use PHPStan\Rules\Comparison\ConstantConditionInTraitHelper;
1013
use PHPStan\Rules\Rule;
1114
use PHPStan\Rules\RuleErrorBuilder;
1215
use PHPStan\Type\NeverType;
@@ -25,6 +28,7 @@ public function __construct(
2528
private ExceptionTypeResolver $exceptionTypeResolver,
2629
#[AutowiredParameter(ref: '%exceptions.reportUncheckedExceptionDeadCatch%')]
2730
private bool $reportUncheckedExceptionDeadCatch,
31+
private ConstantConditionInTraitHelper $constantConditionInTraitHelper,
2832
)
2933
{
3034
}
@@ -34,41 +38,56 @@ public function getNodeType(): string
3438
return CatchWithUnthrownExceptionNode::class;
3539
}
3640

37-
public function processNode(Node $node, Scope $scope): array
41+
public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array
3842
{
3943
if ($node->getCaughtType() instanceof NeverType) {
40-
return [
41-
RuleErrorBuilder::message(
42-
sprintf('Dead catch - %s is already caught above.', $node->getOriginalCaughtType()->describe(VerbosityLevel::typeOnly())),
43-
)
44-
->line($node->getStartLine())
45-
->identifier('catch.alreadyCaught')
46-
->build(),
47-
];
48-
}
44+
$error = RuleErrorBuilder::message(
45+
sprintf('Dead catch - %s is already caught above.', $node->getOriginalCaughtType()->describe(VerbosityLevel::typeOnly())),
46+
)
47+
->line($node->getStartLine())
48+
->identifier('catch.alreadyCaught')
49+
->build();
50+
} else {
51+
if (!$this->reportUncheckedExceptionDeadCatch) {
52+
$isCheckedException = false;
53+
foreach ($node->getCaughtType()->getObjectClassNames() as $objectClassName) {
54+
if ($this->exceptionTypeResolver->isCheckedException($objectClassName, $scope)) {
55+
$isCheckedException = true;
56+
break;
57+
}
58+
}
4959

50-
if (!$this->reportUncheckedExceptionDeadCatch) {
51-
$isCheckedException = false;
52-
foreach ($node->getCaughtType()->getObjectClassNames() as $objectClassName) {
53-
if ($this->exceptionTypeResolver->isCheckedException($objectClassName, $scope)) {
54-
$isCheckedException = true;
55-
break;
60+
if (!$isCheckedException) {
61+
return [];
5662
}
5763
}
5864

59-
if (!$isCheckedException) {
60-
return [];
61-
}
62-
}
63-
64-
return [
65-
RuleErrorBuilder::message(
65+
$error = RuleErrorBuilder::message(
6666
sprintf('Dead catch - %s is never thrown in the try block.', $node->getCaughtType()->describe(VerbosityLevel::typeOnly())),
6767
)
6868
->line($node->getStartLine())
6969
->identifier('catch.neverThrown')
70-
->build(),
71-
];
70+
->build();
71+
}
72+
73+
if ($scope->isInTrait()) {
74+
// A trait's catch can be dead in the context of one class using the trait and
75+
// alive in the context of another, e.g. when it depends on whether an abstract
76+
// method gets overridden. Let the collector compare the verdicts of all the
77+
// classes using the trait instead of reporting right away; the alive ones are
78+
// recorded by CatchWithThrownExceptionInTraitRule under the same key.
79+
$this->constantConditionInTraitHelper->emitErrorForKey(
80+
self::class,
81+
$scope,
82+
$node->getOriginalNode(),
83+
DeadCatchInTraitKey::create($node->getOriginalNode(), $node->getOriginalCaughtType()),
84+
true,
85+
$error,
86+
);
87+
return [];
88+
}
89+
90+
return [$error];
7291
}
7392

7493
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Exceptions;
4+
5+
use PhpParser\Node\Stmt\Catch_;
6+
use PHPStan\Type\Type;
7+
use function implode;
8+
use function sprintf;
9+
10+
/**
11+
* Identifies one caught type of one catch clause in a trait, so that the dead-catch
12+
* verdicts collected from every class using the trait can be compared.
13+
*
14+
* The trait is parsed from the same file for every using class, so the caught type
15+
* together with the line pins down the occurrence: `catch (A|B)` yields two keys,
16+
* one per caught type.
17+
*/
18+
final class DeadCatchInTraitKey
19+
{
20+
21+
public static function create(Catch_ $catchNode, Type $originalCaughtType): string
22+
{
23+
return sprintf('%s:%d', implode('|', $originalCaughtType->getObjectClassNames()), $catchNode->getStartLine());
24+
}
25+
26+
}

0 commit comments

Comments
 (0)