Skip to content

Commit d5e5e38

Browse files
committed
feat(rules): Add ExceptionMustImplementNativeThrowableRule
- Introduced a new PHPStan rule to enforce that exception classes implement the native Throwable interface. - Updated rules configuration to include the new rule. - Enhanced code quality and consistency in exception handling across the project.
1 parent c702a5b commit d5e5e38

16 files changed

+313
-24
lines changed

.php-cs-fixer.dist.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
Finder::create()
5656
->in(__DIR__)
5757
->exclude([
58-
'Fixtures/',
58+
// 'Fixtures/',
5959
'vendor-bin/',
6060
])
6161
->notPath([

composer-dependency-analyser.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
->ignoreErrorsOnPackages(
3939
[
4040
'illuminate/support',
41-
'phpstan/phpstan',
4241
],
4342
[ErrorType::UNUSED_DEPENDENCY]
4443
)

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@
416416
"sk:namespace-to-psr-4-tests": "@sk namespace-to-psr-4 tests/ --namespace-root=Guanguans\\PHPStanRulesTests\\",
417417
"sk:pretty-json": "@sk pretty-json .lintmdrc",
418418
"sk:pretty-json-dry-run": "@sk:pretty-json --dry-run",
419-
"sk:privatize-constants": "@sk privatize-constants src/",
419+
"sk:privatize-constants": "@sk privatize-constants src/ --exclude-path=src/Rule/",
420420
"sk:search-regex": "@sk search-regex 'Guanguans.*ValetDrivers'",
421421
"sk:split-config-per-package": "@sk split-config-per-package monorepo-builder.php",
422422
"sk:spot-lazy-traits": "@sk spot-lazy-traits src/ --max-used=2",

config/.gitkeep

Whitespace-only changes.

config/rules.neon

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
conditionalTags:
2+
Guanguans\PHPStanRules\Rule\ExceptionMustImplementNativeThrowableRule:
3+
phpstan.rules.rule: %guanguans.exceptionMustImplementNativeThrowable.enabled%
4+
5+
parameters:
6+
guanguans:
7+
allRules: true
8+
exceptionMustImplementNativeThrowable:
9+
implement: \Throwable
10+
enabled: %guanguans.allRules%
11+
12+
parametersSchema:
13+
guanguans: structure([
14+
allRules: bool()
15+
exceptionMustImplementNativeThrowable: structure([
16+
implement: string()
17+
enabled: bool(),
18+
])
19+
])
20+
21+
services:
22+
-
23+
class: Guanguans\PHPStanRules\Rule\ExceptionMustImplementNativeThrowableRule
24+
arguments:
25+
implement: %guanguans.exceptionMustImplementNativeThrowable.implement%

phpstan.neon.dist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
includes:
22
- baselines/loader.neon
33
# - phpstan-baseline.neon
4+
- config/rules.neon
45

56
- vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon
67
- vendor/spaze/phpstan-disallowed-calls/disallowed-execution-calls.neon
@@ -58,6 +59,12 @@ parameters:
5859
enabled: false
5960
noParameterWithNullDefaultValue:
6061
enabled: false
62+
noConstructorParameterWithDefaultValue:
63+
enabled: false
64+
guanguans:
65+
exceptionMustImplementNativeThrowable:
66+
implement: Guanguans\PHPStanRules\Contract\ThrowableContract
67+
enabled: false
6168
cognitive_complexity:
6269
class: 42
6370
function: 8
@@ -133,6 +140,7 @@ parameters:
133140
# - identifier: encapsedStringPart.nonString
134141
# - identifier: method.childParameterType
135142
- identifier: return.type
143+
- identifier: phpstanApi.runtimeReflection
136144
- identifier: symplify.explicitInterfaceSuffixName
137145
# - identifier: symplify.forbiddenArrayMethodCall
138146
- identifier: symplify.forbiddenNode

rector.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
->withRootFiles()
6868
->withSkip([
6969
'**/Fixtures/*',
70+
'**/Source/*',
7071
__DIR__.'/_ide_helper.php',
7172
// __DIR__.'/tests.php',
7273
])
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright (c) 2026 guanguans<ityaozm@gmail.com>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*
11+
* @see https://github.com/guanguans/phpstan-rules
12+
*/
13+
14+
namespace Guanguans\PHPStanRules\Rule;
15+
16+
use PhpParser\Node;
17+
use PhpParser\Node\Expr\New_;
18+
use PhpParser\Node\Name;
19+
use PHPStan\Analyser\Scope;
20+
use PHPStan\Rules\Rule;
21+
use PHPStan\Rules\RuleErrorBuilder;
22+
23+
/**
24+
* @see \Guanguans\PHPStanRulesTests\Rule\ExceptionMustImplementNativeThrowableRule\ExceptionMustImplementNativeThrowableRuleTest
25+
* @see \Guanguans\RectorRules\Rector\New_\NewExceptionToNewAnonymousExtendsExceptionImplementsRector
26+
* @see https://github.com/symfony/ai/blob/main/.phpstan/ForbidNativeExceptionRule.php
27+
* @see https://github.com/thecodingmachine/phpstan-strict-rules/tree/master/src/Rules/Exceptions/
28+
*
29+
* @implements Rule<Node\Expr\New_>
30+
*/
31+
final class ExceptionMustImplementNativeThrowableRule implements Rule
32+
{
33+
/** @api */
34+
public const ERROR_MESSAGE = 'The exception [%s] must implement the native throwable [%s].';
35+
private string $implement;
36+
37+
/**
38+
* @param class-string<\Throwable> $implement
39+
*/
40+
public function __construct(string $implement)
41+
{
42+
$this->implement = $implement;
43+
}
44+
45+
public function getNodeType(): string
46+
{
47+
return New_::class;
48+
}
49+
50+
/**
51+
* @param \PhpParser\Node\Expr\New_ $node
52+
*
53+
* @throws \PHPStan\ShouldNotHappenException
54+
*/
55+
public function processNode(Node $node, Scope $scope): array
56+
{
57+
if (
58+
/** 暂不处理匿名类 `new class() extends Exception {}` 的情况. */
59+
!$node->class instanceof Name
60+
|| !is_subclass_of($class = $node->class->toString(), \Throwable::class)
61+
|| is_subclass_of($class, $this->implement)
62+
) {
63+
return [];
64+
}
65+
66+
return [
67+
RuleErrorBuilder::message($this->createErrorMessage($class))
68+
->identifier('guanguans.exceptionMustImplementNativeThrowable')
69+
->build(),
70+
];
71+
}
72+
73+
private function createErrorMessage(string $class): string
74+
{
75+
return \sprintf(self::ERROR_MESSAGE, $class, $this->implement);
76+
}
77+
}

src/Rule/ExceptionRule.php

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/** @noinspection AnonymousFunctionStaticInspection */
4+
/** @noinspection NullPointerExceptionInspection */
5+
/** @noinspection PhpPossiblePolymorphicInvocationInspection */
6+
/** @noinspection PhpUndefinedClassInspection */
7+
/** @noinspection PhpUnhandledExceptionInspection */
8+
/** @noinspection PhpVoidFunctionResultUsedInspection */
9+
/** @noinspection StaticClosureCanBeUsedInspection */
10+
declare(strict_types=1);
11+
12+
/**
13+
* Copyright (c) 2026 guanguans<ityaozm@gmail.com>
14+
*
15+
* For the full copyright and license information, please view
16+
* the LICENSE file that was distributed with this source code.
17+
*
18+
* @see https://github.com/guanguans/phpstan-rules
19+
*/
20+
21+
namespace Guanguans\PHPStanRulesTests\Rule\ExceptionMustImplementNativeThrowableRule;
22+
23+
use Guanguans\PHPStanRules\Contract\ThrowableContract;
24+
use Guanguans\PHPStanRules\Rule\ExceptionMustImplementNativeThrowableRule;
25+
use PHPStan\Rules\Rule;
26+
use PHPStan\Testing\RuleTestCase;
27+
use Webmozart\Assert\Assert;
28+
29+
final class ExceptionMustImplementNativeThrowableRuleTest extends RuleTestCase
30+
{
31+
/**
32+
* @dataProvider provideRuleCases()
33+
*
34+
* @param array<int, list<int|string>> $expectedErrorMessagesWithLines
35+
*
36+
* @noinspection PhpUndefinedNamespaceInspection
37+
* @noinspection PhpLanguageLevelInspection
38+
* @noinspection PhpFullyQualifiedNameUsageInspection
39+
*/
40+
#[\PHPUnit\Framework\Attributes\DataProvider('provideRuleCases')]
41+
public function testRule(string $filePath, array $expectedErrorMessagesWithLines): void
42+
{
43+
Assert::allInteger(array_keys($expectedErrorMessagesWithLines));
44+
$this->analyse([$filePath], $expectedErrorMessagesWithLines);
45+
}
46+
47+
/**
48+
* @return \Iterator<array<array<int, mixed>, mixed>>
49+
*/
50+
public static function provideRuleCases(): iterable
51+
{
52+
$errorMessage = \sprintf(
53+
ExceptionMustImplementNativeThrowableRule::ERROR_MESSAGE,
54+
\Exception::class,
55+
ThrowableContract::class
56+
);
57+
58+
yield [__DIR__.'/Fixtures/ExceptionMustImplementNativeThrowable.php', [[$errorMessage, 21]]];
59+
60+
yield [__DIR__.'/Fixtures/SkipAnonymousClass.php', []];
61+
62+
yield [__DIR__.'/Fixtures/SkipAnonymousClass.php', []];
63+
64+
yield [__DIR__.'/Fixtures/SkipNonThrowable.php', []];
65+
}
66+
67+
/**
68+
* @return list<string>
69+
*/
70+
public static function getAdditionalConfigFiles(): array
71+
{
72+
return [__DIR__.'/config/configured_rule.neon'];
73+
}
74+
75+
protected function getRule(): Rule
76+
{
77+
return self::getContainer()->getByType(ExceptionMustImplementNativeThrowableRule::class);
78+
}
79+
}

0 commit comments

Comments
 (0)