Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ parameters:
hookRules: false
loggerFromFactoryPropertyAssignmentRule: false
entityStorageDirectInjectionRule: false
constraintValidatorMustNarrowConstraintType: false
entityMapping:
aggregator_feed:
class: Drupal\aggregator\Entity\Feed
Expand Down Expand Up @@ -275,6 +276,7 @@ parametersSchema:
hookRules: boolean()
loggerFromFactoryPropertyAssignmentRule: boolean()
entityStorageDirectInjectionRule: boolean()
constraintValidatorMustNarrowConstraintType: boolean()
])
entityMapping: arrayOf(anyOf(
structure([
Expand Down
4 changes: 4 additions & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ conditionalTags:
phpstan.rules.rule: %drupal.rules.entityStorageDirectInjectionRule%
mglaman\PHPStanDrupal\Rules\Drupal\EntityStoragePropertyAssignmentRule:
phpstan.rules.rule: %drupal.rules.entityStorageDirectInjectionRule%
mglaman\PHPStanDrupal\Rules\Drupal\ConstraintValidatorValidateNarrowsConstraintTypeRule:
phpstan.rules.rule: %drupal.rules.constraintValidatorMustNarrowConstraintType%

services:
-
Expand All @@ -58,3 +60,5 @@ services:
class: mglaman\PHPStanDrupal\Rules\Drupal\EntityStorageDirectInjectionRule
-
class: mglaman\PHPStanDrupal\Rules\Drupal\EntityStoragePropertyAssignmentRule
-
class: mglaman\PHPStanDrupal\Rules\Drupal\ConstraintValidatorValidateNarrowsConstraintTypeRule
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 [];
Comment thread
mglaman marked this conversation as resolved.
}

// 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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processNode() will report an error for abstract validate() methods (or interface methods) because $node->stmts is null and you pass an empty array into hasConstraintAssertion(). In those cases the suggested fix (adding an assert in the method body) is impossible, so this should be skipped (e.g., return [] when $node->stmts === null or when the method is abstract).

Suggested change
// 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 uses AI. Check for mistakes.
return [];
}

$validatorFqcn = $classReflection->getName();
$validatorPos = strrpos($validatorFqcn, '\\');
$validatorShortName = $validatorPos !== false ? substr($validatorFqcn, $validatorPos + 1) : $validatorFqcn;
$constraintFqcn = $constraintReflection->getName();
$constraintPos = strrpos($constraintFqcn, '\\');
$constraintShortName = $constraintPos !== false ? substr($constraintFqcn, $constraintPos + 1) : $constraintFqcn;

return [
RuleErrorBuilder::message(sprintf(
'Method %s::validate() does not narrow the $%s parameter type. Add assert($%s instanceof %s) to allow PHPStan to infer constraint-specific properties.',
$validatorShortName,
$constraintParamName,
$constraintParamName,
$constraintShortName
))
->identifier('drupal.constraintValidator.missingNarrow')
->tip('See https://www.drupal.org/project/drupal/issues/3246287')
->build(),
];
}

/**
* Returns true if the statement list contains assert($var instanceof ExpectedConstraintClass).
*
* @param Node\Stmt[] $stmts
*/
private function hasConstraintAssertion(array $stmts, string $paramName, string $expectedConstraintFqcn, Scope $scope): bool
{
foreach ($stmts as $stmt) {
if (!$stmt instanceof Node\Stmt\Expression) {
continue;
}
$expr = $stmt->expr;
if (!$expr instanceof Node\Expr\FuncCall) {
continue;
}
if (!$expr->name instanceof Node\Name || $expr->name->toString() !== 'assert') {
continue;
}
$args = $expr->getArgs();
if (count($args) === 0) {
continue;
}
$assertArg = $args[0]->value;
if (!$assertArg instanceof Node\Expr\Instanceof_) {
continue;
}
if (!$assertArg->expr instanceof Node\Expr\Variable) {
continue;
}
if ($assertArg->expr->name !== $paramName) {
continue;
}
if (!$assertArg->class instanceof Node\Name) {
continue;
}
$assertedClass = $scope->resolveName($assertArg->class);
if ($assertedClass === $expectedConstraintFqcn) {
return true;
}
Comment thread
mglaman marked this conversation as resolved.
Comment on lines +125 to +156
Copy link

Copilot AI Apr 15, 2026

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.

Copilot uses AI. Check for mistakes.
}

return false;
}
}
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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests currently cover only the simplest assert-at-top-level case. Since the rule is sensitive to AST shape (e.g., assert nested in an if block, or a non-standard $constraint parameter name), add fixtures/tests for those scenarios to prevent regressions and to validate the intended detection behavior.

Suggested change
}
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;
}
}

Copilot uses AI. Check for mistakes.
70 changes: 70 additions & 0 deletions tests/src/Rules/data/constraint-validator.php
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');
}

}
Loading