$npx -y skills add Lonsdale201/wp-agent-skills --skill bd-validation-ruleAdd a new validation rule to better-data — implement
| 1 | # better-data: Adding a validation rule |
| 2 | |
| 3 | For library maintainers introducing a new built-in validation rule (`Rule\CreditCard`, `Rule\PhoneE164`, `Rule\StrongPassword`, etc.). Rules are tiny, pure, attribute-decorated classes that the validator iterates per field. The contract is small but precise — getting it wrong (throwing instead of returning, applying to nulls, embedding side effects) breaks compositional rules and surfaces messy errors to consumers. |
| 4 | |
| 5 | ## Misconception this skill corrects |
| 6 | |
| 7 | > "I'll throw an exception with the validation error message inside `check()` — easier than a return-string protocol." |
| 8 | |
| 9 | The contract at [src/Validation/Rule.php:25-28](Rule.php) is: |
| 10 | |
| 11 | ```php |
| 12 | interface Rule |
| 13 | { |
| 14 | public function check(mixed $value, string $fieldName, DataObject $subject): ?string; |
| 15 | } |
| 16 | ``` |
| 17 | |
| 18 | `null` = pass, short error string = fail. Throwing is the engine's prerogative, not an individual rule's. The `BuiltInValidator` ([src/Validation/BuiltInValidator.php](BuiltInValidator.php)) iterates rules across many fields and accumulates errors; an exception would short-circuit the entire validation pass and return a single error instead of the complete failure list — which is what `ValidationResult` is for. |
| 19 | |
| 20 | Other AI-prone misconceptions: |
| 21 | |
| 22 | - "Rule\Foo extends Rule\Email." Wrong — rules don't compose via inheritance; PHP's attribute reflection looks up exact class names. Add a new rule. |
| 23 | - "The interface is RuleInterface." Wrong — it's literally `Rule`. The AGENTS.md docs in older drafts referred to it as `RuleInterface`, but the actual file is `src/Validation/Rule.php` and the interface is `Rule`. Use that name. |
| 24 | - "Rules apply to null values too — I want to validate that `?Email $email = null` is non-null." Wrong — convention is `null` means "skip"; if you want non-null, add `#[Rule\Required]` *also*. Single responsibility. |
| 25 | |
| 26 | ## When to use this skill |
| 27 | |
| 28 | Trigger when ANY of the following is true: |
| 29 | |
| 30 | - Creating a new file under `src/Validation/Rule/`. |
| 31 | - The diff adds `implements Rule` (or implements the deprecated `RuleInterface`). |
| 32 | - Adding a new `#[Rule\Foo]` attribute to a DTO and you can't find `Foo` in `src/Validation/Rule/`. |
| 33 | - Reviewing a PR that throws inside a `check()` method — flag and convert to return-string. |
| 34 | |
| 35 | ## Workflow |
| 36 | |
| 37 | ### 1. File layout |
| 38 | |
| 39 | ``` |
| 40 | src/Validation/Rule/CreditCard.php |
| 41 | tests/Unit/Validation/Rule/CreditCardTest.php ← optional, or co-located |
| 42 | tests/Unit/ValidationTest.php ← shared rule tests |
| 43 | ``` |
| 44 | |
| 45 | ### 2. Class shape (use Required.php as the template) |
| 46 | |
| 47 | ```php |
| 48 | <?php |
| 49 | |
| 50 | declare(strict_types=1); |
| 51 | |
| 52 | namespace BetterData\Validation\Rule; |
| 53 | |
| 54 | use Attribute; |
| 55 | use BetterData\DataObject; |
| 56 | use BetterData\Validation\Rule; |
| 57 | |
| 58 | #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] |
| 59 | final readonly class CreditCard implements Rule |
| 60 | { |
| 61 | public function __construct( |
| 62 | public bool $allowTestNumbers = false, |
| 63 | ) {} |
| 64 | |
| 65 | public function check(mixed $value, string $fieldName, DataObject $subject): ?string |
| 66 | { |
| 67 | if ($value === null) { |
| 68 | return null; // null = skip; pair with #[Required] if presence matters |
| 69 | } |
| 70 | |
| 71 | if (!\is_string($value)) { |
| 72 | return 'must be a string of digits'; |
| 73 | } |
| 74 | |
| 75 | $digits = \preg_replace('/\s+/', '', $value); |
| 76 | |
| 77 | if ($digits === null || !\preg_match('/^\d{12,19}$/', $digits)) { |
| 78 | return 'must be a valid card number'; |
| 79 | } |
| 80 | |
| 81 | if (!self::luhnPasses($digits)) { |
| 82 | return 'failed checksum'; |
| 83 | } |
| 84 | |
| 85 | if (!$this->allowTestNumbers && self::isTestCard($digits)) { |
| 86 | return 'test card numbers are not accepted'; |
| 87 | } |
| 88 | |
| 89 | return null; |
| 90 | } |
| 91 | |
| 92 | private static function luhnPasses(string $digits): bool { /* ... */ } |
| 93 | private static function isTestCar |