From a6fc18dc5700d1198437c3aef0e3fa92cf0dbfb7 Mon Sep 17 00:00:00 2001 From: Shakur Date: Thu, 6 Aug 2026 12:16:49 +0600 Subject: [PATCH 1/5] fix: correct validation logic in BetweenRule and update test cases --- src/Rules/BetweenRule.php | 7 +++---- tests/Rules/BetweenTest.php | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Rules/BetweenRule.php b/src/Rules/BetweenRule.php index 67bb79e..b0fc754 100644 --- a/src/Rules/BetweenRule.php +++ b/src/Rules/BetweenRule.php @@ -22,12 +22,11 @@ public function validate($value) $length = $this->getValueLength($value); - if ($length) { - return $length >= $min && $length <= $max; + if ($length === false) { + return false; } - return false; - + return $length >= $min && $length <= $max; } public function getParamKeys() diff --git a/tests/Rules/BetweenTest.php b/tests/Rules/BetweenTest.php index b251e84..0abc410 100644 --- a/tests/Rules/BetweenTest.php +++ b/tests/Rules/BetweenTest.php @@ -6,10 +6,11 @@ $rule = new BetweenRule(); $paramKeys = ['min', 'max']; - $paramValues = [1, 5]; + $paramValues = [0, 5]; $rule->setParameterValues($paramKeys, $paramValues); expect(true)->toBe($rule->validate('abcde')); + expect(true)->toBe($rule->validate(0)); 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 }); From 5bb3f8941406e3dea2c65dfb59417dbd42018ad0 Mon Sep 17 00:00:00 2001 From: Shakur Date: Thu, 6 Aug 2026 12:24:41 +0600 Subject: [PATCH 2/5] fix: update validation logic in MaxRule and MinRule, enhance test coverage for Between, Max, and Min rules --- src/Rules/MaxRule.php | 7 +++---- src/Rules/MinRule.php | 7 +++---- tests/Rules/BetweenTest.php | 31 +++++++++++++++++++++++++++++-- tests/Rules/MaxTest.php | 31 ++++++++++++++++++++++++++++++- tests/Rules/MinTest.php | 31 +++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/Rules/MaxRule.php b/src/Rules/MaxRule.php index 3f2647c..b109f67 100644 --- a/src/Rules/MaxRule.php +++ b/src/Rules/MaxRule.php @@ -20,12 +20,11 @@ public function validate($value) $length = $this->getValueLength($value); - if ($length) { - return $length <= $max; + if ($length === false) { + return false; } - return false; - + return $length <= $max; } public function getParamKeys() diff --git a/src/Rules/MinRule.php b/src/Rules/MinRule.php index a7dae52..9d572f9 100644 --- a/src/Rules/MinRule.php +++ b/src/Rules/MinRule.php @@ -20,12 +20,11 @@ public function validate($value) $length = $this->getValueLength($value); - if ($length) { - return $length >= $min; + if ($length === false) { + return false; } - return false; - + return $length >= $min; } public function getParamKeys() diff --git a/tests/Rules/BetweenTest.php b/tests/Rules/BetweenTest.php index 0abc410..7deaaa3 100644 --- a/tests/Rules/BetweenTest.php +++ b/tests/Rules/BetweenTest.php @@ -6,11 +6,38 @@ $rule = new BetweenRule(); $paramKeys = ['min', 'max']; - $paramValues = [0, 5]; + $paramValues = [1, 5]; $rule->setParameterValues($paramKeys, $paramValues); expect(true)->toBe($rule->validate('abcde')); - expect(true)->toBe($rule->validate(0)); expect(true)->toBe($rule->validate(3)); 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())); }); From 29653156169afe7bb6c7f291b1eb59b0d26ed87b Mon Sep 17 00:00:00 2001 From: Shakur Date: Thu, 6 Aug 2026 16:56:10 +0600 Subject: [PATCH 3/5] feat: implement lengthWithin method for inclusive range checks, refactor Between, Max, and Min rules to utilize new method; add test-watch script and update README --- .gitattributes | 3 +- README.md | 9 +++ bin/test-watch | 144 ++++++++++++++++++++++++++++++++++++++ composer.json | 1 + src/Helpers.php | 22 ++++++ src/Rules/BetweenRule.php | 8 +-- src/Rules/MaxRule.php | 8 +-- src/Rules/MinRule.php | 8 +-- 8 files changed, 181 insertions(+), 22 deletions(-) create mode 100755 bin/test-watch diff --git a/.gitattributes b/.gitattributes index 7b65935..262320f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ /tests export-ignore +/bin export-ignore /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/README.md b/README.md index 33e1612..0e396e3 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,15 @@ It will refer to the value parameter of `size` validation rule. We welcome contributions from the community. If you find a bug or have a feature suggestion, please open an issue or submit a pull request. +### Running Tests + +```bash +composer test:unit # run the suite once +composer test:watch # re-run it whenever a PHP file changes +``` + +Run `./bin/test-watch -h` for the watcher's options. + # License This package is open-source and available under the MIT License. diff --git a/bin/test-watch b/bin/test-watch new file mode 100755 index 0000000..a86fbb1 --- /dev/null +++ b/bin/test-watch @@ -0,0 +1,144 @@ +#!/bin/sh +# +# Re-run the unit test suite whenever a PHP file under src/ or tests/ changes. +# +# Usage: +# composer test:watch +# ./bin/test-watch [-i INTERVAL] [-- ] +# +# Options: +# -i INTERVAL Seconds between change checks (default: 2) +# -h Show this help +# +# Uses plain polling so it works anywhere PHP does, with no extra dependencies. + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +WATCH_DIRS="src tests" +INTERVAL=2 + +usage() { + cat <<'EOF' +Re-run the unit test suite whenever a PHP file under src/ or tests/ changes. + +Usage: + composer test:watch + ./bin/test-watch [-i INTERVAL] [-- ] + +Options: + -i INTERVAL Seconds between change checks (default: 2) + -h Show this help +EOF +} + +while [ $# -gt 0 ]; do + case $1 in + -i) + [ $# -ge 2 ] || { echo "test-watch: -i requires a value" >&2; exit 1; } + INTERVAL=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + echo "test-watch: unknown option '$1'" >&2 + usage >&2 + exit 1 + ;; + esac +done + +case $INTERVAL in + ''|*[!0-9.]*) + echo "test-watch: interval must be a number, got '$INTERVAL'" >&2 + exit 1 + ;; +esac + +cd "$ROOT_DIR" + +if ! command -v composer >/dev/null 2>&1; then + echo "test-watch: composer not found on PATH." >&2 + exit 1 +fi + +if [ ! -x vendor/bin/pest ]; then + echo "test-watch: vendor/bin/pest not found. Run 'composer install' first." >&2 + exit 1 +fi + +# Resolve the watched dirs and the available find(1) dialect once, not per tick. +WATCH_EXISTING='' +for dir in $WATCH_DIRS; do + [ -d "$dir" ] && WATCH_EXISTING="$WATCH_EXISTING $dir" +done + +if [ -z "$WATCH_EXISTING" ]; then + echo "test-watch: none of the watched directories ($WATCH_DIRS) exist." >&2 + exit 1 +fi + +if find . -maxdepth 0 -printf '' 2>/dev/null; then + HAVE_PRINTF=1 +else + HAVE_PRINTF=0 +fi + +# Build a fingerprint of every watched PHP file: size + mtime + path. +# Any add, delete, or edit changes the resulting string. +# +# %T@ is a fractional-second epoch mtime, so same-size edits made within +# the same second are still detected. GNU find only; the stat fallback +# covers BSD/macOS. +fingerprint() { + # shellcheck disable=SC2086 + if [ "$HAVE_PRINTF" = 1 ]; then + find $WATCH_EXISTING -type f -name '*.php' -printf '%s %T@ %p\n' 2>/dev/null | sort + else + find $WATCH_EXISTING -type f -name '*.php' \ + -exec stat -f '%z %m %N' {} + 2>/dev/null | sort + fi +} + +run_tests() { + printf '\033[2J\033[H' + echo "──────────────────────────────────────────────────" + echo " Running unit tests…" + echo "──────────────────────────────────────────────────" + echo + + # Delegate to the canonical suite definition in composer.json so the + # watcher can never drift from `composer test:unit`. + # Never let a failing suite kill the watcher. + if composer --no-interaction test:unit -- "$@"; then + status='PASS' + else + status='FAIL' + fi + + echo + echo "──────────────────────────────────────────────────" + echo " $status · watching src/ and tests/ · Ctrl-C to stop" + echo "──────────────────────────────────────────────────" +} + +trap 'printf "\ntest-watch: stopped.\n"; exit 0' INT TERM + +previous=$(fingerprint) +run_tests "$@" + +while true; do + sleep "$INTERVAL" + current=$(fingerprint) + if [ "$current" != "$previous" ]; then + previous=$current + run_tests "$@" + fi +done diff --git a/composer.json b/composer.json index a08059e..0561c97 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ }, "scripts":{ "test:unit":"./vendor/bin/pest --testdox --colors=always tests/ --exclude-group db", + "test:watch":"./bin/test-watch", "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibility --runtime-set testVersion 7.2", "rector": "./vendor/bin/rector process --dry-run" }, 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 b0fc754..b8f1b6a 100644 --- a/src/Rules/BetweenRule.php +++ b/src/Rules/BetweenRule.php @@ -20,13 +20,7 @@ public function validate($value) $max = (int) $this->getParameter('max'); - $length = $this->getValueLength($value); - - if ($length === false) { - return false; - } - - return $length >= $min && $length <= $max; + return $this->lengthWithin($value, $min, $max); } public function getParamKeys() diff --git a/src/Rules/MaxRule.php b/src/Rules/MaxRule.php index b109f67..b2ed800 100644 --- a/src/Rules/MaxRule.php +++ b/src/Rules/MaxRule.php @@ -18,13 +18,7 @@ public function validate($value) $max = (int) $this->getParameter('max'); - $length = $this->getValueLength($value); - - if ($length === false) { - return false; - } - - return $length <= $max; + return $this->lengthWithin($value, null, $max); } public function getParamKeys() diff --git a/src/Rules/MinRule.php b/src/Rules/MinRule.php index 9d572f9..a6cfde2 100644 --- a/src/Rules/MinRule.php +++ b/src/Rules/MinRule.php @@ -18,13 +18,7 @@ public function validate($value) $min = (int) $this->getParameter('min'); - $length = $this->getValueLength($value); - - if ($length === false) { - return false; - } - - return $length >= $min; + return $this->lengthWithin($value, $min); } public function getParamKeys() From b4cc0f68fb0846434d9205feb15d0322051875d0 Mon Sep 17 00:00:00 2001 From: Shakur Date: Tue, 15 Sep 2026 12:26:11 +0600 Subject: [PATCH 4/5] chore: remove test-watch script and update related documentation; clean up .gitattributes and composer.json --- .gitattributes | 1 - CLAUDE.md | 83 ++++++++++++++++++++++++++++ README.md | 9 ---- bin/test-watch | 144 ------------------------------------------------- composer.json | 1 - 5 files changed, 83 insertions(+), 155 deletions(-) create mode 100644 CLAUDE.md delete mode 100755 bin/test-watch diff --git a/.gitattributes b/.gitattributes index 262320f..44a56ef 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,4 @@ /tests export-ignore -/bin export-ignore /phpunit.xml export-ignore .gitattributes export-ignore .gitignore 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/README.md b/README.md index 0e396e3..33e1612 100644 --- a/README.md +++ b/README.md @@ -326,15 +326,6 @@ It will refer to the value parameter of `size` validation rule. We welcome contributions from the community. If you find a bug or have a feature suggestion, please open an issue or submit a pull request. -### Running Tests - -```bash -composer test:unit # run the suite once -composer test:watch # re-run it whenever a PHP file changes -``` - -Run `./bin/test-watch -h` for the watcher's options. - # License This package is open-source and available under the MIT License. diff --git a/bin/test-watch b/bin/test-watch deleted file mode 100755 index a86fbb1..0000000 --- a/bin/test-watch +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/sh -# -# Re-run the unit test suite whenever a PHP file under src/ or tests/ changes. -# -# Usage: -# composer test:watch -# ./bin/test-watch [-i INTERVAL] [-- ] -# -# Options: -# -i INTERVAL Seconds between change checks (default: 2) -# -h Show this help -# -# Uses plain polling so it works anywhere PHP does, with no extra dependencies. - -set -eu - -ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -WATCH_DIRS="src tests" -INTERVAL=2 - -usage() { - cat <<'EOF' -Re-run the unit test suite whenever a PHP file under src/ or tests/ changes. - -Usage: - composer test:watch - ./bin/test-watch [-i INTERVAL] [-- ] - -Options: - -i INTERVAL Seconds between change checks (default: 2) - -h Show this help -EOF -} - -while [ $# -gt 0 ]; do - case $1 in - -i) - [ $# -ge 2 ] || { echo "test-watch: -i requires a value" >&2; exit 1; } - INTERVAL=$2 - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift - break - ;; - *) - echo "test-watch: unknown option '$1'" >&2 - usage >&2 - exit 1 - ;; - esac -done - -case $INTERVAL in - ''|*[!0-9.]*) - echo "test-watch: interval must be a number, got '$INTERVAL'" >&2 - exit 1 - ;; -esac - -cd "$ROOT_DIR" - -if ! command -v composer >/dev/null 2>&1; then - echo "test-watch: composer not found on PATH." >&2 - exit 1 -fi - -if [ ! -x vendor/bin/pest ]; then - echo "test-watch: vendor/bin/pest not found. Run 'composer install' first." >&2 - exit 1 -fi - -# Resolve the watched dirs and the available find(1) dialect once, not per tick. -WATCH_EXISTING='' -for dir in $WATCH_DIRS; do - [ -d "$dir" ] && WATCH_EXISTING="$WATCH_EXISTING $dir" -done - -if [ -z "$WATCH_EXISTING" ]; then - echo "test-watch: none of the watched directories ($WATCH_DIRS) exist." >&2 - exit 1 -fi - -if find . -maxdepth 0 -printf '' 2>/dev/null; then - HAVE_PRINTF=1 -else - HAVE_PRINTF=0 -fi - -# Build a fingerprint of every watched PHP file: size + mtime + path. -# Any add, delete, or edit changes the resulting string. -# -# %T@ is a fractional-second epoch mtime, so same-size edits made within -# the same second are still detected. GNU find only; the stat fallback -# covers BSD/macOS. -fingerprint() { - # shellcheck disable=SC2086 - if [ "$HAVE_PRINTF" = 1 ]; then - find $WATCH_EXISTING -type f -name '*.php' -printf '%s %T@ %p\n' 2>/dev/null | sort - else - find $WATCH_EXISTING -type f -name '*.php' \ - -exec stat -f '%z %m %N' {} + 2>/dev/null | sort - fi -} - -run_tests() { - printf '\033[2J\033[H' - echo "──────────────────────────────────────────────────" - echo " Running unit tests…" - echo "──────────────────────────────────────────────────" - echo - - # Delegate to the canonical suite definition in composer.json so the - # watcher can never drift from `composer test:unit`. - # Never let a failing suite kill the watcher. - if composer --no-interaction test:unit -- "$@"; then - status='PASS' - else - status='FAIL' - fi - - echo - echo "──────────────────────────────────────────────────" - echo " $status · watching src/ and tests/ · Ctrl-C to stop" - echo "──────────────────────────────────────────────────" -} - -trap 'printf "\ntest-watch: stopped.\n"; exit 0' INT TERM - -previous=$(fingerprint) -run_tests "$@" - -while true; do - sleep "$INTERVAL" - current=$(fingerprint) - if [ "$current" != "$previous" ]; then - previous=$current - run_tests "$@" - fi -done diff --git a/composer.json b/composer.json index 0561c97..a08059e 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,6 @@ }, "scripts":{ "test:unit":"./vendor/bin/pest --testdox --colors=always tests/ --exclude-group db", - "test:watch":"./bin/test-watch", "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibility --runtime-set testVersion 7.2", "rector": "./vendor/bin/rector process --dry-run" }, From 8f190c43cea768331a3ebb4ea4f00a37cdec1696 Mon Sep 17 00:00:00 2001 From: Shakur Date: Tue, 15 Sep 2026 12:29:14 +0600 Subject: [PATCH 5/5] fix: remove test-watch script and update README and composer.json accordingly --- .gitattributes | 1 - CLAUDE.md | 83 ++++++++++++++++++++++++++++ README.md | 9 ---- bin/test-watch | 144 ------------------------------------------------- composer.json | 1 - 5 files changed, 83 insertions(+), 155 deletions(-) create mode 100644 CLAUDE.md delete mode 100755 bin/test-watch diff --git a/.gitattributes b/.gitattributes index 262320f..44a56ef 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,4 @@ /tests export-ignore -/bin export-ignore /phpunit.xml export-ignore .gitattributes export-ignore .gitignore 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/README.md b/README.md index 0e396e3..33e1612 100644 --- a/README.md +++ b/README.md @@ -326,15 +326,6 @@ It will refer to the value parameter of `size` validation rule. We welcome contributions from the community. If you find a bug or have a feature suggestion, please open an issue or submit a pull request. -### Running Tests - -```bash -composer test:unit # run the suite once -composer test:watch # re-run it whenever a PHP file changes -``` - -Run `./bin/test-watch -h` for the watcher's options. - # License This package is open-source and available under the MIT License. diff --git a/bin/test-watch b/bin/test-watch deleted file mode 100755 index a86fbb1..0000000 --- a/bin/test-watch +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/sh -# -# Re-run the unit test suite whenever a PHP file under src/ or tests/ changes. -# -# Usage: -# composer test:watch -# ./bin/test-watch [-i INTERVAL] [-- ] -# -# Options: -# -i INTERVAL Seconds between change checks (default: 2) -# -h Show this help -# -# Uses plain polling so it works anywhere PHP does, with no extra dependencies. - -set -eu - -ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -WATCH_DIRS="src tests" -INTERVAL=2 - -usage() { - cat <<'EOF' -Re-run the unit test suite whenever a PHP file under src/ or tests/ changes. - -Usage: - composer test:watch - ./bin/test-watch [-i INTERVAL] [-- ] - -Options: - -i INTERVAL Seconds between change checks (default: 2) - -h Show this help -EOF -} - -while [ $# -gt 0 ]; do - case $1 in - -i) - [ $# -ge 2 ] || { echo "test-watch: -i requires a value" >&2; exit 1; } - INTERVAL=$2 - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - --) - shift - break - ;; - *) - echo "test-watch: unknown option '$1'" >&2 - usage >&2 - exit 1 - ;; - esac -done - -case $INTERVAL in - ''|*[!0-9.]*) - echo "test-watch: interval must be a number, got '$INTERVAL'" >&2 - exit 1 - ;; -esac - -cd "$ROOT_DIR" - -if ! command -v composer >/dev/null 2>&1; then - echo "test-watch: composer not found on PATH." >&2 - exit 1 -fi - -if [ ! -x vendor/bin/pest ]; then - echo "test-watch: vendor/bin/pest not found. Run 'composer install' first." >&2 - exit 1 -fi - -# Resolve the watched dirs and the available find(1) dialect once, not per tick. -WATCH_EXISTING='' -for dir in $WATCH_DIRS; do - [ -d "$dir" ] && WATCH_EXISTING="$WATCH_EXISTING $dir" -done - -if [ -z "$WATCH_EXISTING" ]; then - echo "test-watch: none of the watched directories ($WATCH_DIRS) exist." >&2 - exit 1 -fi - -if find . -maxdepth 0 -printf '' 2>/dev/null; then - HAVE_PRINTF=1 -else - HAVE_PRINTF=0 -fi - -# Build a fingerprint of every watched PHP file: size + mtime + path. -# Any add, delete, or edit changes the resulting string. -# -# %T@ is a fractional-second epoch mtime, so same-size edits made within -# the same second are still detected. GNU find only; the stat fallback -# covers BSD/macOS. -fingerprint() { - # shellcheck disable=SC2086 - if [ "$HAVE_PRINTF" = 1 ]; then - find $WATCH_EXISTING -type f -name '*.php' -printf '%s %T@ %p\n' 2>/dev/null | sort - else - find $WATCH_EXISTING -type f -name '*.php' \ - -exec stat -f '%z %m %N' {} + 2>/dev/null | sort - fi -} - -run_tests() { - printf '\033[2J\033[H' - echo "──────────────────────────────────────────────────" - echo " Running unit tests…" - echo "──────────────────────────────────────────────────" - echo - - # Delegate to the canonical suite definition in composer.json so the - # watcher can never drift from `composer test:unit`. - # Never let a failing suite kill the watcher. - if composer --no-interaction test:unit -- "$@"; then - status='PASS' - else - status='FAIL' - fi - - echo - echo "──────────────────────────────────────────────────" - echo " $status · watching src/ and tests/ · Ctrl-C to stop" - echo "──────────────────────────────────────────────────" -} - -trap 'printf "\ntest-watch: stopped.\n"; exit 0' INT TERM - -previous=$(fingerprint) -run_tests "$@" - -while true; do - sleep "$INTERVAL" - current=$(fingerprint) - if [ "$current" != "$previous" ]; then - previous=$current - run_tests "$@" - fi -done diff --git a/composer.json b/composer.json index 0561c97..a08059e 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,6 @@ }, "scripts":{ "test:unit":"./vendor/bin/pest --testdox --colors=always tests/ --exclude-group db", - "test:watch":"./bin/test-watch", "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibility --runtime-set testVersion 7.2", "rector": "./vendor/bin/rector process --dry-run" },