-
-
Notifications
You must be signed in to change notification settings - Fork 86
Add opt-in rule requiring ConstraintValidator::validate() to narrow the constraint type #977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,161 @@ | ||||||||||||||||||
| <?php | ||||||||||||||||||
|
|
||||||||||||||||||
| declare(strict_types=1); | ||||||||||||||||||
|
|
||||||||||||||||||
| namespace mglaman\PHPStanDrupal\Rules\Drupal; | ||||||||||||||||||
|
|
||||||||||||||||||
| use PhpParser\Node; | ||||||||||||||||||
| use PHPStan\Analyser\Scope; | ||||||||||||||||||
| use PHPStan\Reflection\ReflectionProvider; | ||||||||||||||||||
| use PHPStan\Rules\Rule; | ||||||||||||||||||
| use PHPStan\Rules\RuleErrorBuilder; | ||||||||||||||||||
| use Symfony\Component\Validator\Constraint; | ||||||||||||||||||
| use Symfony\Component\Validator\ConstraintValidator; | ||||||||||||||||||
| use function array_key_exists; | ||||||||||||||||||
| use function count; | ||||||||||||||||||
| use function str_ends_with; | ||||||||||||||||||
| use function strrpos; | ||||||||||||||||||
| use function substr; | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Requires ConstraintValidator::validate() to narrow the $constraint parameter type. | ||||||||||||||||||
| * | ||||||||||||||||||
| * PHPStan cannot infer that `$constraint` is a specific subclass of Constraint | ||||||||||||||||||
| * inside a validator's validate() method. Drupal core's convention is to use | ||||||||||||||||||
| * `assert($constraint instanceof SpecificConstraint)` to narrow the type, which | ||||||||||||||||||
| * PHPStan handles natively. | ||||||||||||||||||
| * | ||||||||||||||||||
| * This rule fires when a class following the FooValidator/Foo naming convention | ||||||||||||||||||
| * does not assert the constraint type in its validate() method. | ||||||||||||||||||
| * | ||||||||||||||||||
| * @implements Rule<Node\Stmt\ClassMethod> | ||||||||||||||||||
| */ | ||||||||||||||||||
| final class ConstraintValidatorValidateNarrowsConstraintTypeRule implements Rule | ||||||||||||||||||
| { | ||||||||||||||||||
|
|
||||||||||||||||||
| private ReflectionProvider $reflectionProvider; | ||||||||||||||||||
|
|
||||||||||||||||||
| public function __construct(ReflectionProvider $reflectionProvider) | ||||||||||||||||||
| { | ||||||||||||||||||
| $this->reflectionProvider = $reflectionProvider; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| public function getNodeType(): string | ||||||||||||||||||
| { | ||||||||||||||||||
| return Node\Stmt\ClassMethod::class; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| public function processNode(Node $node, Scope $scope): array | ||||||||||||||||||
| { | ||||||||||||||||||
| if ($node->name->toString() !== 'validate') { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
| if (!$scope->isInClass()) { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Guard: symfony/validator may not be installed in every project. | ||||||||||||||||||
| if (!$this->reflectionProvider->hasClass(ConstraintValidator::class) | ||||||||||||||||||
| || !$this->reflectionProvider->hasClass(Constraint::class)) { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| $classReflection = $scope->getClassReflection(); | ||||||||||||||||||
| if (!$classReflection->isSubclassOfClass($this->reflectionProvider->getClass(ConstraintValidator::class))) { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Derive the matching constraint class name by stripping 'Validator' suffix. | ||||||||||||||||||
| $fqcn = $classReflection->getName(); | ||||||||||||||||||
| if (!str_ends_with($fqcn, 'Validator')) { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
| $constraintClass = substr($fqcn, 0, -9); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!$this->reflectionProvider->hasClass($constraintClass)) { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
| $constraintReflection = $this->reflectionProvider->getClass($constraintClass); | ||||||||||||||||||
| if (!$constraintReflection->isSubclassOfClass($this->reflectionProvider->getClass(Constraint::class))) { | ||||||||||||||||||
| return []; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Find the $constraint parameter name (second param by Symfony convention). | ||||||||||||||||||
| $constraintParamName = 'constraint'; | ||||||||||||||||||
| if (array_key_exists(1, $node->params)) { | ||||||||||||||||||
| $param = $node->params[1]; | ||||||||||||||||||
| if ($param->var instanceof Node\Expr\Variable && is_string($param->var->name)) { | ||||||||||||||||||
| $constraintParamName = $param->var->name; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Check if the method body asserts the concrete constraint type. | ||||||||||||||||||
| if ($this->hasConstraintAssertion($node->stmts ?? [], $constraintParamName, $constraintClass, $scope)) { | ||||||||||||||||||
|
Comment on lines
+92
to
+93
|
||||||||||||||||||
| // Check if the method body asserts the concrete constraint type. | |
| if ($this->hasConstraintAssertion($node->stmts ?? [], $constraintParamName, $constraintClass, $scope)) { | |
| if ($node->stmts === null) { | |
| return []; | |
| } | |
| // Check if the method body asserts the concrete constraint type. | |
| if ($this->hasConstraintAssertion($node->stmts, $constraintParamName, $constraintClass, $scope)) { |
Copilot
AI
Apr 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hasConstraintAssertion() only inspects top-level Expression statements, so it will miss valid narrowing asserts that appear inside control-flow blocks (e.g., inside an if/try) or other nested statements. This can lead to false positives even though the method body does contain the required assert; consider using a NodeFinder (or recursive traversal) to search the whole statement subtree for an assert($param instanceof Expected) call.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,33 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <?php | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| declare(strict_types=1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| namespace mglaman\PHPStanDrupal\Tests\Rules; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use mglaman\PHPStanDrupal\Rules\Drupal\ConstraintValidatorValidateNarrowsConstraintTypeRule; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use mglaman\PHPStanDrupal\Tests\DrupalRuleTestCase; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use PHPStan\Rules\Rule; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final class ConstraintValidatorValidateNarrowsConstraintTypeRuleTest extends DrupalRuleTestCase | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| protected function getRule(): Rule | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self::getContainer()->getByType(ConstraintValidatorValidateNarrowsConstraintTypeRule::class); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public function testRule(): void | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $this->analyse( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [__DIR__ . '/data/constraint-validator.php'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Method UniqueItemValidator::validate() does not narrow the $constraint parameter type. Add assert($constraint instanceof UniqueItem) to allow PHPStan to infer constraint-specific properties.', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 22, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'See https://www.drupal.org/project/drupal/issues/3246287', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+32
to
+33
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | |
| public function testRuleAllowsNestedAssert(): void | |
| { | |
| $fixture = $this->createFixture(<<<'PHP' | |
| <?php | |
| declare(strict_types=1); | |
| use Symfony\Component\Validator\Constraint; | |
| use Symfony\Component\Validator\ConstraintValidator; | |
| final class UniqueItem extends Constraint | |
| { | |
| public string $message = 'Invalid'; | |
| } | |
| final class NestedAssertValidator extends ConstraintValidator | |
| { | |
| public function validate(mixed $value, Constraint $constraint): void | |
| { | |
| if ($value !== null) { | |
| assert($constraint instanceof UniqueItem); | |
| $constraint->message; | |
| } | |
| } | |
| } | |
| PHP); | |
| try { | |
| $this->analyse([$fixture], []); | |
| } finally { | |
| @unlink($fixture); | |
| } | |
| } | |
| public function testRuleAllowsNonStandardConstraintParameterName(): void | |
| { | |
| $fixture = $this->createFixture(<<<'PHP' | |
| <?php | |
| declare(strict_types=1); | |
| use Symfony\Component\Validator\Constraint; | |
| use Symfony\Component\Validator\ConstraintValidator; | |
| final class UniqueItem extends Constraint | |
| { | |
| public string $message = 'Invalid'; | |
| } | |
| final class AlternateNameValidator extends ConstraintValidator | |
| { | |
| public function validate(mixed $value, Constraint $customConstraint): void | |
| { | |
| assert($customConstraint instanceof UniqueItem); | |
| $customConstraint->message; | |
| } | |
| } | |
| PHP); | |
| try { | |
| $this->analyse([$fixture], []); | |
| } finally { | |
| @unlink($fixture); | |
| } | |
| } | |
| private function createFixture(string $contents): string | |
| { | |
| $fixture = tempnam(sys_get_temp_dir(), 'constraint-validator-'); | |
| if ($fixture === false) { | |
| self::fail('Failed to create temporary fixture file.'); | |
| } | |
| $path = $fixture . '.php'; | |
| if (!rename($fixture, $path)) { | |
| @unlink($fixture); | |
| self::fail('Failed to prepare temporary fixture file.'); | |
| } | |
| if (file_put_contents($path, $contents) === false) { | |
| @unlink($path); | |
| self::fail('Failed to write temporary fixture file.'); | |
| } | |
| return $path; | |
| } | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace mglaman\PHPStanDrupal\Tests\Rules\data\ConstraintValidator; | ||
|
|
||
| use Symfony\Component\Validator\Constraint; | ||
| use Symfony\Component\Validator\ConstraintValidator; | ||
|
|
||
| // Constraint class following the naming convention: UniqueItem / UniqueItemValidator. | ||
| class UniqueItem extends Constraint | ||
| { | ||
|
|
||
| public string $alreadyExists = 'The item %value already exists.'; | ||
|
|
||
| } | ||
|
|
||
| // Error: validate() does not assert the concrete constraint type. | ||
| class UniqueItemValidator extends ConstraintValidator | ||
| { | ||
|
|
||
| public function validate(mixed $value, Constraint $constraint): void | ||
| { | ||
| $this->context->addViolation($constraint->alreadyExists); | ||
| } | ||
|
|
||
| } | ||
|
|
||
| // Constraint class for the assert-detection test case. | ||
| class UniqueItemWithAssert extends Constraint | ||
| { | ||
|
|
||
| public string $alreadyExists = 'The item %value already exists.'; | ||
|
|
||
| } | ||
|
|
||
| // No error: validate() uses assert() to narrow to the expected constraint type. | ||
| class UniqueItemWithAssertValidator extends ConstraintValidator | ||
| { | ||
|
|
||
| public function validate(mixed $value, Constraint $constraint): void | ||
| { | ||
| assert($constraint instanceof UniqueItemWithAssert); | ||
| $this->context->addViolation($constraint->alreadyExists); | ||
| } | ||
|
|
||
| } | ||
|
|
||
| // No error: 'StandaloneValidator' strips to 'Standalone', but there is no | ||
| // corresponding constraint class with that name. | ||
| class StandaloneValidator extends ConstraintValidator | ||
| { | ||
|
|
||
| public function validate(mixed $value, Constraint $constraint): void | ||
| { | ||
| $this->context->addViolation('some message'); | ||
| } | ||
|
|
||
| } | ||
|
|
||
| // No error: 'OrphanedValidator' strips to 'Orphaned', which is not a known constraint. | ||
| class OrphanedValidator extends ConstraintValidator | ||
| { | ||
|
|
||
| public function validate(mixed $value, Constraint $constraint): void | ||
| { | ||
| $this->context->addViolation('some message'); | ||
| } | ||
|
|
||
| } |
Uh oh!
There was an error while loading. Please reload this page.