Skip to content

Commit f1f378e

Browse files
committed
Add PHPStan rule forbidding native exceptions and switch remaining ones
1 parent 059db10 commit f1f378e

28 files changed

+254
-21
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\PHPStan;
13+
14+
use PhpParser\Node;
15+
use PhpParser\Node\Expr\New_;
16+
use PhpParser\Node\Expr\Throw_;
17+
use PhpParser\Node\Name;
18+
use PhpParser\Node\Stmt\Catch_;
19+
use PHPStan\Analyser\Scope;
20+
use PHPStan\Rules\Rule;
21+
use PHPStan\Rules\RuleError;
22+
use PHPStan\Rules\RuleErrorBuilder;
23+
24+
/**
25+
* PHPStan rule that forbids usage of native PHP exceptions in favor of subpackage-specific exceptions.
26+
*
27+
* This rule enforces the use of component-specific exception classes instead of native PHP exceptions
28+
* for better error handling and consistency across the Symfony AI monorepo.
29+
*
30+
* @implements Rule<Node>
31+
*/
32+
final class ForbidNativeExceptionRule implements Rule
33+
{
34+
private const FORBIDDEN_EXCEPTIONS = [
35+
\Exception::class,
36+
\InvalidArgumentException::class,
37+
\RuntimeException::class,
38+
\LogicException::class,
39+
\BadMethodCallException::class,
40+
\BadFunctionCallException::class,
41+
\DomainException::class,
42+
\LengthException::class,
43+
\OutOfBoundsException::class,
44+
\OutOfRangeException::class,
45+
\OverflowException::class,
46+
\RangeException::class,
47+
\UnderflowException::class,
48+
\UnexpectedValueException::class,
49+
];
50+
51+
private const PACKAGE_EXCEPTION_NAMESPACES = [
52+
'Symfony\\AI\\Agent' => 'Symfony\\AI\\Agent\\Exception\\',
53+
'Symfony\\AI\\Platform' => 'Symfony\\AI\\Platform\\Exception\\',
54+
'Symfony\\AI\\Store' => 'Symfony\\AI\\Store\\Exception\\',
55+
'Symfony\\AI\\McpSdk' => 'Symfony\\AI\\McpSdk\\Exception\\',
56+
'Symfony\\AI\\AIBundle' => 'Symfony\\AI\\AIBundle\\Exception\\',
57+
'Symfony\\AI\\McpBundle' => 'Symfony\\AI\\McpBundle\\Exception\\',
58+
];
59+
60+
public function getNodeType(): string
61+
{
62+
return Node::class;
63+
}
64+
65+
public function processNode(Node $node, Scope $scope): array
66+
{
67+
$errors = [];
68+
69+
if ($node instanceof New_ && $node->class instanceof Name) {
70+
$exceptionClass = $node->class->toString();
71+
if ($this->isForbiddenException($exceptionClass)) {
72+
$errors[] = $this->createError($node, $exceptionClass, $scope, 'instantiation');
73+
}
74+
}
75+
76+
if ($node instanceof Throw_ && $node->expr instanceof New_ && $node->expr->class instanceof Name) {
77+
$exceptionClass = $node->expr->class->toString();
78+
if ($this->isForbiddenException($exceptionClass)) {
79+
$errors[] = $this->createError($node, $exceptionClass, $scope, 'throw');
80+
}
81+
}
82+
83+
return $errors;
84+
}
85+
86+
private function isForbiddenException(string $exceptionClass): bool
87+
{
88+
// Remove leading backslash if present
89+
$exceptionClass = ltrim($exceptionClass, '\\');
90+
91+
// Check if it's a native PHP exception
92+
return in_array($exceptionClass, self::FORBIDDEN_EXCEPTIONS, true);
93+
}
94+
95+
private function createError(Node $node, string $exceptionClass, Scope $scope, string $context): RuleError
96+
{
97+
$currentNamespace = $scope->getNamespace();
98+
99+
if (null === $currentNamespace) {
100+
throw new \RuntimeException('All classes should have a namespace.');
101+
}
102+
103+
$suggestedNamespace = $this->getSuggestedExceptionNamespace($currentNamespace);
104+
105+
$message = sprintf(
106+
'Use of native PHP exception "%s" is forbidden in %s context. Use "%s%s" instead.',
107+
$exceptionClass,
108+
$context,
109+
$suggestedNamespace,
110+
$exceptionClass
111+
);
112+
113+
return RuleErrorBuilder::message($message)
114+
->line($node->getLine())
115+
->identifier('symfonyAi.forbidNativeException')
116+
->tip(sprintf(
117+
'Replace "%s" with "%s%s" to use a package-specific exception.',
118+
$exceptionClass,
119+
$suggestedNamespace,
120+
$exceptionClass
121+
))
122+
->build();
123+
}
124+
125+
private function getSuggestedExceptionNamespace(string $currentNamespace): string
126+
{
127+
foreach (self::PACKAGE_EXCEPTION_NAMESPACES as $packageNamespace => $exceptionNamespace) {
128+
if (str_starts_with($currentNamespace, $packageNamespace)) {
129+
return $exceptionNamespace;
130+
}
131+
}
132+
133+
throw new \RuntimeException(sprintf('Unexpected namespace "%s".', $currentNamespace));
134+
}
135+
}

.phpstan/extension.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
rules:
2+
- Symfony\AI\PHPStan\ForbidNativeExceptionRule

src/agent/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
"autoload-dev": {
6161
"psr-4": {
6262
"Symfony\\AI\\Agent\\Tests\\": "tests/",
63-
"Symfony\\AI\\Fixtures\\": "../../fixtures"
63+
"Symfony\\AI\\Fixtures\\": "../../fixtures",
64+
"Symfony\\AI\\PHPStan\\": "../../.phpstan/"
6465
}
6566
}
6667
}

src/agent/phpstan.dist.neon

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
includes:
2+
- ../../.phpstan/extension.neon
3+
14
parameters:
25
level: 6
36
paths:
@@ -7,4 +10,6 @@ parameters:
710
-
811
identifier: missingType.iterableValue
912
path: tests/*
10-
13+
-
14+
identifier: 'symfonyAi.forbidNativeException'
15+
path: tests/*

src/agent/src/Chat/MessageStore/CacheStore.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Cache\CacheItemPoolInterface;
1515
use Symfony\AI\Agent\Chat\MessageStoreInterface;
16+
use Symfony\AI\Agent\Exception\RuntimeException;
1617
use Symfony\AI\Platform\Message\MessageBag;
1718
use Symfony\AI\Platform\Message\MessageBagInterface;
1819

@@ -24,7 +25,7 @@ public function __construct(
2425
private int $ttl = 86400,
2526
) {
2627
if (!interface_exists(CacheItemPoolInterface::class)) {
27-
throw new \RuntimeException('For using the CacheStore as message store, a PSR-6 cache implementation is required. Try running "composer require symfony/cache" or another PSR-6 compatible cache.');
28+
throw new RuntimeException('For using the CacheStore as message store, a PSR-6 cache implementation is required. Try running "composer require symfony/cache" or another PSR-6 compatible cache.');
2829
}
2930
}
3031

src/agent/src/Chat/MessageStore/SessionStore.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\AI\Agent\Chat\MessageStore;
1313

1414
use Symfony\AI\Agent\Chat\MessageStoreInterface;
15+
use Symfony\AI\Agent\Exception\RuntimeException;
1516
use Symfony\AI\Platform\Message\MessageBag;
1617
use Symfony\AI\Platform\Message\MessageBagInterface;
1718
use Symfony\Component\HttpFoundation\RequestStack;
@@ -26,7 +27,7 @@ public function __construct(
2627
private string $sessionKey = 'messages',
2728
) {
2829
if (!class_exists(RequestStack::class)) {
29-
throw new \RuntimeException('For using the SessionStore as message store, the symfony/http-foundation package is required. Try running "composer require symfony/http-foundation".');
30+
throw new RuntimeException('For using the SessionStore as message store, the symfony/http-foundation package is required. Try running "composer require symfony/http-foundation".');
3031
}
3132
$this->session = $requestStack->getSession();
3233
}

src/ai-bundle/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
},
4040
"autoload-dev": {
4141
"psr-4": {
42-
"Symfony\\AI\\AIBundle\\Tests\\": "tests/"
42+
"Symfony\\AI\\AIBundle\\Tests\\": "tests/",
43+
"Symfony\\AI\\PHPStan\\": "../../.phpstan/"
4344
}
4445
}
4546
}

src/ai-bundle/phpstan.dist.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
includes:
2+
- ../../.phpstan/extension.neon
3+
14
parameters:
25
level: 6
36
paths:

src/ai-bundle/src/AIBundle.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory;
2525
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
2626
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
27+
use Symfony\AI\AIBundle\Exception\InvalidArgumentException;
2728
use Symfony\AI\AIBundle\Profiler\DataCollector;
2829
use Symfony\AI\AIBundle\Profiler\TraceablePlatform;
2930
use Symfony\AI\AIBundle\Profiler\TraceableToolbox;
@@ -137,7 +138,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
137138
$builder->removeDefinition('symfony_ai.security.is_granted_attribute_listener');
138139
$builder->registerAttributeForAutoconfiguration(
139140
IsGrantedTool::class,
140-
static fn () => throw new \InvalidArgumentException('Using #[IsGrantedTool] attribute requires additional dependencies. Try running "composer install symfony/security-core".'),
141+
static fn () => throw new InvalidArgumentException('Using #[IsGrantedTool] attribute requires additional dependencies. Try running "composer install symfony/security-core".'),
141142
);
142143
}
143144

@@ -255,7 +256,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
255256
return;
256257
}
257258

258-
throw new \InvalidArgumentException(\sprintf('Platform "%s" is not supported for configuration via bundle at this point.', $type));
259+
throw new InvalidArgumentException(\sprintf('Platform "%s" is not supported for configuration via bundle at this point.', $type));
259260
}
260261

261262
/**
@@ -267,7 +268,7 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde
267268
['class' => $modelClass, 'name' => $modelName, 'options' => $options] = $config['model'];
268269

269270
if (!is_a($modelClass, Model::class, true)) {
270-
throw new \InvalidArgumentException(\sprintf('"%s" class is not extending Symfony\AI\Platform\Model.', $modelClass));
271+
throw new InvalidArgumentException(\sprintf('"%s" class is not extending Symfony\AI\Platform\Model.', $modelClass));
271272
}
272273

273274
$modelDefinition = new Definition($modelClass);
@@ -474,7 +475,7 @@ private function processIndexerConfig(int|string $name, array $config, Container
474475
['class' => $modelClass, 'name' => $modelName, 'options' => $options] = $config['model'];
475476

476477
if (!is_a($modelClass, Model::class, true)) {
477-
throw new \InvalidArgumentException(\sprintf('"%s" class is not extending Symfony\AI\Platform\Model.', $modelClass));
478+
throw new InvalidArgumentException(\sprintf('"%s" class is not extending Symfony\AI\Platform\Model.', $modelClass));
478479
}
479480

480481
$modelDefinition = (new Definition((string) $modelClass));
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AIBundle\Exception;
13+
14+
/**
15+
* @author Christopher Hertel <[email protected]>
16+
*/
17+
interface ExceptionInterface extends \Throwable
18+
{
19+
}

0 commit comments

Comments
 (0)