Skip to content

Add opt-in rule requiring ConstraintValidator::validate() to narrow the constraint type#977

Open
mglaman wants to merge 2 commits intomainfrom
feat/constraint-validator-narrow-rule
Open

Add opt-in rule requiring ConstraintValidator::validate() to narrow the constraint type#977
mglaman wants to merge 2 commits intomainfrom
feat/constraint-validator-narrow-rule

Conversation

@mglaman
Copy link
Copy Markdown
Owner

@mglaman mglaman commented Apr 15, 2026

Summary

Closes #466

PHPStan reports "Access to an undefined property" when a ConstraintValidator subclass accesses constraint-specific properties, because $constraint is typed as the base Symfony\Component\Validator\Constraint class in the method signature. phpstan-symfony does not handle this use case (phpstan/phpstan-symfony#16).

Drupal core's own convention — visible in ConfigExistsConstraintValidator — is to add assert($constraint instanceof SpecificConstraint) at the top of validate(), which PHPStan narrows natively.

What changed

  • New opt-in rule ConstraintValidatorValidateNarrowsConstraintTypeRule that fires when a class following the FooValidator/Foo naming convention does not narrow $constraint in its validate() method
  • The rule suggests the exact assert() call to add
  • Registered as an opt-in conditional rule (default false), enabled with:
parameters:
    drupal:
        rules:
            constraintValidatorMustNarrowConstraintType: true

How it works

  1. Finds validate() methods in classes extending ConstraintValidator
  2. Strips the Validator suffix from the FQCN to derive the expected constraint class (e.g. UniqueItemValidatorUniqueItem)
  3. Confirms the derived class exists and extends Constraint
  4. Checks if the method body contains assert($constraint instanceof MatchingClass) — if not, reports an error

Test plan

  • New fixture covers: error case (no assert), no-error case (with assert), no-error case (no naming-convention match)
  • php vendor/bin/phpunit --filter=ConstraintValidatorValidateNarrowsConstraintTypeRuleTest passes
  • Full test suite (772 tests) passes with no regressions
  • php vendor/bin/phpstan analyze passes on the new rule file
  • php vendor/bin/phpcs passes on the new rule file

🤖 Generated with Claude Code

…he constraint type

PHPStan reports "Access to an undefined property" when a ConstraintValidator
subclass accesses constraint-specific properties because $constraint is typed
as the base Symfony\Component\Validator\Constraint class. phpstan-symfony does
not handle this (phpstan/phpstan-symfony#16).

The new ConstraintValidatorValidateNarrowsConstraintTypeRule detects validators
following the FooValidator/Foo naming convention and requires an
`assert($constraint instanceof Foo)` assertion in validate(), matching Drupal
core's own convention. The rule is opt-in via
`parameters: drupal: rules: constraintValidatorMustNarrowConstraintType: true`.

Closes #466

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an opt-in PHPStan rule to enforce Drupal’s convention that ConstraintValidator::validate() narrows the concrete constraint type (via assert($constraint instanceof …)), helping PHPStan infer constraint-specific properties and avoid “undefined property” reports.

Changes:

  • Introduces ConstraintValidatorValidateNarrowsConstraintTypeRule to detect missing type-narrowing asserts based on the FooValidatorFoo naming convention.
  • Adds a PHPUnit rule test and a fixture file demonstrating expected behaviors.
  • Registers the rule as an opt-in conditional rule via extension.neon + rules.neon.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Rules/Drupal/ConstraintValidatorValidateNarrowsConstraintTypeRule.php New rule implementation that derives the expected constraint class and checks for a narrowing assert() in validate().
tests/src/Rules/ConstraintValidatorValidateNarrowsConstraintTypeRuleTest.php New test case wiring the rule into the existing DrupalRuleTestCase harness.
tests/src/Rules/data/constraint-validator.php New fixture file containing example constraints/validators for the rule to analyze.
rules.neon Registers the rule as a conditional (opt-in) PHPStan rule.
extension.neon Adds the opt-in parameter and schema entry for enabling the rule.

Comment thread tests/src/Rules/data/constraint-validator.php Outdated
Comment thread tests/src/Rules/data/constraint-validator.php Outdated
Comment thread tests/src/Rules/data/constraint-validator.php Outdated
- Add hasClass() guards for ConstraintValidator/Constraint before calling
  getClass(), so the rule exits cleanly when symfony/validator is absent
- Verify the asserted class in assert() matches the expected constraint FQCN,
  preventing false negatives from assert($constraint instanceof WrongClass)
- Rename UniqueItemValidatorWithAssert → UniqueItemWithAssertValidator so the
  assert-detection path is actually exercised (naming convention was wrong)
- Fix misleading comment on StandaloneValidator (it does match the suffix
  convention; no error because no matching constraint class exists)
- Remove unused ExecutionContextInterface import from fixture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment on lines +92 to +93
// Check if the method body asserts the concrete constraint type.
if ($this->hasConstraintAssertion($node->stmts ?? [], $constraintParamName, $constraintClass, $scope)) {
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.
Comment on lines +125 to +156
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;
}
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Symfony constraints and unknown properties

2 participants