diff --git a/.gitattributes b/.gitattributes index 7b65935..44a56ef 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,4 @@ /phpunit.xml export-ignore .gitattributes export-ignore .gitignore export-ignore -composer.lock export-ignore \ No newline at end of file +composer.lock export-ignore diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9695a8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +`bitapps/wp-validator` — a Composer library (PSR-4: `BitApps\WPValidator\` → [src/](src/)) providing Laravel-style validation and sanitization for WordPress. Public API and the full rule list are documented in [README.md](README.md); update it when adding a rule or sanitizer. + +**PHP 7.2 is the floor.** No typed properties, no arrow functions, no null coalescing assignment, no union types. Return types on methods are used and fine. [rector.php](rector.php) pins `PhpVersion::PHP_72` and skips the typed-property rectors for this reason. + +## Commands + +```bash +composer test:unit # full suite (Pest, --testdox, excludes group 'db') +./vendor/bin/pest tests/Rules/BetweenTest.php # single file +./vendor/bin/pest --filter='between accepts a length of zero' # single test by name +composer compat # PHPCompatibility check against PHP 7.2 +composer rector # Rector dry-run (never auto-applies) +``` + +There is no `phpunit.xml` — Pest runs on defaults with paths passed explicitly. `.gitattributes` references one that does not exist; harmless. + +## Architecture + +The validation pass flows through four collaborators: + +- [Validator.php](src/Validator.php) — orchestrator. `make()` is the only entry point; it returns `$this` so `fails()` / `errors()` / `validated()` chain off it. +- [InputDataContainer.php](src/InputDataContainer.php) — holds the input array plus the *currently-focused* field key and label. Rules read the value under validation from here rather than receiving the whole dataset. It resolves dot-paths on both get and set, so nested writes from sanitizers land in the right place. +- [Rule.php](src/Rule.php) — abstract base. Subclasses implement `validate($value)` and `message()`. +- [ErrorBag.php](src/ErrorBag.php) — builds the message, resolving custom messages and substituting `:placeholder` tokens. + +### Rule resolution is convention-based + +`resolveRule()` converts a string rule name to a class: `digit_between` → `BitApps\WPValidator\Rules\DigitBetweenRule`. Snake_case becomes StudlyCase and `Rule` is appended. There is no registry — **the class name is the contract**, and [tests/ArchitectureTest.php](tests/ArchitectureTest.php) enforces it (every class in `Rules\` must extend `Rule`, end in `Rule`, and define `validate` + `message`). + +That same arch test bans `die`, `exit`, `error_log`, `var_dump`, and `print_r` anywhere in the codebase. + +### Parameters + +`min:8` and `between:1,5` are parsed by splitting on the first `:`, then on `,`. Positional values are zipped against the rule's `getParamKeys()` into a name→value map. So a parameterized rule needs three things: a `$requireParameters` array, a `getParamKeys()` returning it, and a `checkRequiredParameter()` call at the top of `validate()`. Those same parameter names double as error-message placeholders (`:min`, `:max`, `:size`, `:other`) — `ErrorBag` merges the parameter map straight into the placeholder set, so naming a parameter automatically makes the token available. + +Note `setParameterValues()` silently no-ops when the key and value counts differ; the resulting missing parameter is what `checkRequiredParameter()` catches, throwing `Exception\InvalidArgumentException`. + +### Sanitization runs before validation + +In `validateByRules()`, any rule string containing `sanitize` is stripped out and applied *first*, mutating the value in the container; the remaining rules then validate the sanitized value. `sanitize:html_class` maps to `sanitizeHtmlClass()` in [SanitizationMethods.php](src/SanitizationMethods.php) (prefix + StudlyCase suffix), with `sanitize:wp_kses|a.href,br` passing its pipe-delimited allowlist as a second argument. + +Validation of a field **stops at the first failing rule** (`break`), so each field yields at most one error message per pass. + +### Special-cased rules + +Three rules are not purely self-contained and need care when touching `Validator::validateField()`: + +- `nullable` — intercepted *before* any rule runs. If present and the value is empty, the field short-circuits as valid. `NullableRule::validate()` itself just returns `true`. +- `present` — checks key existence rather than value, and short-circuits the remaining rules when the value is empty. +- `same:other` — reads a sibling field out of the container, not just `$value`. + +### Wildcards + +`by_role.*.path` expands via `processWildcardFieldKey()` into one concrete dot-path per matching entry before validation. Errors are keyed by the *expanded* path, while the custom-label lookup uses the original wildcard string. Note the expansion walks only the **first** key at each `*` level to discover structure — see [tests/ValidateWildcardPathTest.php](tests/ValidateWildcardPathTest.php) for the shape it handles. + +### Empty vs. length + +Two helpers in [Helpers.php](src/Helpers.php) carry subtle semantics that rules must not reimplement: + +- `isEmpty()` treats `'0'`, `0`, `0.0`, and `false` as **non**-empty. +- `getValueLength()` returns `mb_strlen` for strings, `count` for arrays, the number itself for ints/floats, and `false` for anything unmeasurable (bool, null, object). `false` is distinct from length `0` — a length of 0 is valid. + +`lengthWithin($value, $min, $max)` wraps both and is what `between`, `min`, `max`, and `size` should use (`null` for an open bound) rather than comparing lengths by hand. + +## Testing + +Pest, with `uses()->compact()` in [tests/Pest.php](tests/Pest.php). Rule tests overwhelmingly instantiate the rule directly and call `validate()` — no `Validator` needed unless the behavior involves the container, wildcards, or rule interaction: + +```php +$rule = new BetweenRule(); +$rule->setParameterValues(['min', 'max'], [1, 5]); +expect(true)->toBe($rule->validate('abcde')); +``` + +**WordPress is not loaded and there are no WP function stubs.** Everything in `SanitizationMethods` calls WP core (`sanitize_text_field`, `wp_kses`, `esc_url_raw`, …) and will fatal outside WordPress, which is why no test covers sanitization. Adding sanitizer tests means introducing stubs (e.g. Brain Monkey) first — don't assume the harness is there. + +The `db` group is excluded from `test:unit`; nothing currently uses it. diff --git a/src/Helpers.php b/src/Helpers.php index 1859266..efbb824 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -25,6 +25,28 @@ protected function getValueLength($value) } + /** + * Check a value's length against an inclusive range. + * + * A length of 0 is a valid length; only values with no measurable + * length at all (bool, null, object) fail outright. Pass null for + * an open-ended bound. + */ + protected function lengthWithin($value, $min = null, $max = null): bool + { + $length = $this->getValueLength($value); + + if ($length === false) { + return false; + } + + if ($min !== null && $length < $min) { + return false; + } + + return $max === null || $length <= $max; + } + public function setNestedElement(&$data, $keys, $value) { $current = &$data; diff --git a/src/Rules/BetweenRule.php b/src/Rules/BetweenRule.php index 67bb79e..b8f1b6a 100644 --- a/src/Rules/BetweenRule.php +++ b/src/Rules/BetweenRule.php @@ -20,14 +20,7 @@ public function validate($value) $max = (int) $this->getParameter('max'); - $length = $this->getValueLength($value); - - if ($length) { - return $length >= $min && $length <= $max; - } - - return false; - + return $this->lengthWithin($value, $min, $max); } public function getParamKeys() diff --git a/src/Rules/MaxRule.php b/src/Rules/MaxRule.php index 3f2647c..b2ed800 100644 --- a/src/Rules/MaxRule.php +++ b/src/Rules/MaxRule.php @@ -18,14 +18,7 @@ public function validate($value) $max = (int) $this->getParameter('max'); - $length = $this->getValueLength($value); - - if ($length) { - return $length <= $max; - } - - return false; - + return $this->lengthWithin($value, null, $max); } public function getParamKeys() diff --git a/src/Rules/MinRule.php b/src/Rules/MinRule.php index a7dae52..a6cfde2 100644 --- a/src/Rules/MinRule.php +++ b/src/Rules/MinRule.php @@ -18,14 +18,7 @@ public function validate($value) $min = (int) $this->getParameter('min'); - $length = $this->getValueLength($value); - - if ($length) { - return $length >= $min; - } - - return false; - + return $this->lengthWithin($value, $min); } public function getParamKeys() diff --git a/tests/Rules/BetweenTest.php b/tests/Rules/BetweenTest.php index b251e84..7deaaa3 100644 --- a/tests/Rules/BetweenTest.php +++ b/tests/Rules/BetweenTest.php @@ -10,6 +10,34 @@ $rule->setParameterValues($paramKeys, $paramValues); expect(true)->toBe($rule->validate('abcde')); expect(true)->toBe($rule->validate(3)); - expect(true)->toBe($rule->validate([1, 2, 3, 4, 5])); + expect(true)->toBe($rule->validate(5)); + expect(true)->toBe($rule->validate([1, 2, 3, 4, 5])); // arrays are measured by count, not values + + expect(false)->toBe($rule->validate('abcdef')); + expect(false)->toBe($rule->validate(6)); + expect(false)->toBe($rule->validate([1, 2, 3, 4, 5, 6])); +}); + +test('between accepts a length of zero', function () { + + $rule = new BetweenRule(); + $rule->setParameterValues(['min', 'max'], [0, 5]); + + // A length of 0 is a valid length, not a failure. + expect(true)->toBe($rule->validate(0)); + expect(true)->toBe($rule->validate(0.0)); + expect(true)->toBe($rule->validate('')); + expect(true)->toBe($rule->validate([])); + +}); + +test('between rejects values with no measurable length', function () { + + $rule = new BetweenRule(); + $rule->setParameterValues(['min', 'max'], [0, 5]); + + expect(false)->toBe($rule->validate(true)); + expect(false)->toBe($rule->validate(null)); + expect(false)->toBe($rule->validate(new stdClass())); }); diff --git a/tests/Rules/MaxTest.php b/tests/Rules/MaxTest.php index 8722bb6..e07ba4f 100644 --- a/tests/Rules/MaxTest.php +++ b/tests/Rules/MaxTest.php @@ -12,6 +12,35 @@ $rule->setParameterValues($paramKeys, $paramValues); expect(true)->toBe($rule->validate('110')); expect(true)->toBe($rule->validate('passw')); - // expect(true)->toBe($rule->setParameterValues($paramKeys, $paramValues)->validate([4, 5, 6])); + expect(true)->toBe($rule->validate(5)); + expect(true)->toBe($rule->validate([4, 5, 6])); + + expect(false)->toBe($rule->validate('password')); + expect(false)->toBe($rule->validate(6)); + expect(false)->toBe($rule->validate([1, 2, 3, 4, 5, 6])); + +}); + +test('max accepts a length of zero', function () { + + $rule = new MaxRule(); + $rule->setParameterValues(['max'], [5]); + + // A length of 0 is within any non-negative maximum. + expect(true)->toBe($rule->validate(0)); + expect(true)->toBe($rule->validate(0.0)); + expect(true)->toBe($rule->validate('')); + expect(true)->toBe($rule->validate([])); + +}); + +test('max rejects values with no measurable length', function () { + + $rule = new MaxRule(); + $rule->setParameterValues(['max'], [5]); + + expect(false)->toBe($rule->validate(true)); + expect(false)->toBe($rule->validate(null)); + expect(false)->toBe($rule->validate(new stdClass())); }); diff --git a/tests/Rules/MinTest.php b/tests/Rules/MinTest.php index 91a1e01..b3e584b 100644 --- a/tests/Rules/MinTest.php +++ b/tests/Rules/MinTest.php @@ -12,5 +12,36 @@ expect(true)->toBe($rule->validate('1103d')); expect(true)->toBe($rule->validate('passd')); + expect(true)->toBe($rule->validate('password')); + expect(true)->toBe($rule->validate(5)); + expect(true)->toBe($rule->validate([1, 2, 3, 4, 5])); + + expect(false)->toBe($rule->validate('pass')); + expect(false)->toBe($rule->validate(4)); + expect(false)->toBe($rule->validate([1, 2, 3, 4])); + +}); + +test('min accepts a length of zero', function () { + + $rule = new MinRule(); + $rule->setParameterValues(['min'], [0]); + + // A length of 0 is a valid length, not a failure. + expect(true)->toBe($rule->validate(0)); + expect(true)->toBe($rule->validate(0.0)); + expect(true)->toBe($rule->validate('')); + expect(true)->toBe($rule->validate([])); + +}); + +test('min rejects values with no measurable length', function () { + + $rule = new MinRule(); + $rule->setParameterValues(['min'], [0]); + + expect(false)->toBe($rule->validate(true)); + expect(false)->toBe($rule->validate(null)); + expect(false)->toBe($rule->validate(new stdClass())); });