From 19ef134f198b333e0bf185cbbf7ee40db3e0a36c Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 6 Oct 2026 10:38:49 -0400 Subject: [PATCH 1/2] Pass a tag that is behind main only by CI commits The release-currency check failed whenever main gained a commit, including Dependabot's weekly .github/ bumps, which ship nothing. It now passes a tag behind main only by .github/-only commits, and tells a package on real releases to cut a patch instead of moving a published tag. Same rule as laranail/package-tools' canonical script. --- CHANGELOG.md | 1 + scripts/verify-tag-currency.sh | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa114d..d559ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se ### Changed +- `scripts/verify-tag-currency.sh` passes a tag that is behind `main` only by `.github/`-only commits (a Dependabot bump ships nothing), and tells a package on real releases to cut a patch rather than move a published tag. - `PostIncDecToPreIncDecRector` is skipped. Rector rewrote `$i++` to `++$i` in 21 files and Pint's `increment_style` rewrote every one of them back, so the two gates could never both pass. Formatting belongs to Pint. diff --git a/scripts/verify-tag-currency.sh b/scripts/verify-tag-currency.sh index 368d49c..3b12a97 100755 --- a/scripts/verify-tag-currency.sh +++ b/scripts/verify-tag-currency.sh @@ -91,6 +91,19 @@ fi tags=$(git tag --list 'v*' --sort=v:refname) +# A commit that touches only .github/ ships nothing (the directory is export-ignored), so a tag behind +# the branch by such commits still hands consumers exactly what the branch would. Dependabot adds one +# weekly; without this the scheduled check goes red on every workflow bump, in both tag models. +ci_only() { + local files + files=$(git diff --name-only "$1" "${head}") || return 1 + [ -n "${files}" ] || return 1 + ! printf '%s\n' "${files}" | grep -qvE '^\.github/' +} + +# More than one v* tag means real releases, which are immutable: they get a new patch, never a move. +released() { [ "$(printf '%s\n' "${tags}" | grep -c .)" -gt 1 ]; } + if [ -z "${tags}" ]; then ok "No v* tag, so nothing is pinned." exit 0 @@ -159,7 +172,11 @@ if [ "${commit}" = "${head}" ]; then ok "${highest} is also on ${BRANCH}." else hbehind=$(git rev-list --count "${hcommit}..${head}") + if ci_only "${hcommit}"; then + ok "${highest} is behind ${BRANCH} only by ${hbehind} commit(s) that touch only .github/." + else fail "${highest} is the highest tag and is ${hbehind} commit(s) behind ${BRANCH}. An unconstrained \`composer require\` resolves it, so it must be current or it must not exist." + fi fi fi else @@ -169,7 +186,13 @@ else git log --oneline --no-decorate "${commit}..${head}" | sed 's/^/ /' echo - fail "${current} is behind ${BRANCH}. Move it (git tag -f ${current} ${BRANCH} && git push --force ${REMOTE} ${current}) or cut a new one." + if ci_only "${commit}"; then + ok "${current} is behind ${BRANCH} only by commits that touch only .github/, so nothing a consumer installs is unreleased." + elif released; then + fail "${current} is behind ${BRANCH}. It is a published release, so do not move it: cut ${current%.*}.$(( ${current##*.} + 1 ))." + else + fail "${current} is behind ${BRANCH}. Move it (git tag -f ${current} ${BRANCH} && git push --force ${REMOTE} ${current}) or cut a new one." + fi fi exit "${FAILED}" From b3e053e3adb83d5b8058182f78f9e010d6d3da0e Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 6 Oct 2026 16:19:34 -0400 Subject: [PATCH 2/2] Keep the tests clean under PHPStan 2.3 PHPStan 2.3.0 binds $this inside Closure::call(), so the tests' calls to FormRequest::createDefaultValidator() are now typed as the Validator contract, which has no passes(), and expect() could not resolve its type (44 errors). A typed helper, defaultValidatorFor(), asserts the concrete validator HasFluentRules returns and serves all 68 call sites. Its new unused-variable rule found three checks the tests never made; they now assert ($errors empty, the compiled custom rule present, the caught exception's class) instead of dropping the variables. Passes on both PHPStan 2.2.17 and 2.3.0; src/ is unchanged. --- CHANGELOG.md | 11 +++ tests/BatchDatabaseValidationTest.php | 16 ++-- tests/BatchValidationGuardsTest.php | 22 ++--- .../UnknownFluentRuleMethodTest.php | 4 +- tests/ExpandsWildcardsTest.php | 14 +-- tests/FluentFormRequestTest.php | 92 +++++++++---------- tests/FluentRuleTest.php | 3 + tests/Pest.php | 26 ++++++ tests/RuleSetTest.php | 2 +- 9 files changed, 115 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d559ccc..21504e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,17 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se `main` with no change here. It now asserts the alias resolves to exactly the canonical message, and `require` raises `laranail/package-tools` to `^0.1.3` so a `prefer-lowest` resolve gets a version that registers it. +- **PHPStan 2.3.0 failed the `phpstan` job with 50 errors in `tests/`, with no change here.** It + now binds `$this` inside `Closure::call()`, so `(fn () => $this->createDefaultValidator(...))->call($request)` + is typed as `FormRequest`'s declared return, the Validator contract, which has no `passes()`; + 44 `expect($validator->passes())` calls could not resolve `TValue`, and two + `@phpstan-ignore argument.type` comments stopped matching. The 68 call sites now go through a + typed `defaultValidatorFor()` helper in `tests/Pest.php`, which asserts the concrete + `Illuminate\Validation\Validator` that `HasFluentRules` returns, and the stale ignores are gone. + Its bleeding-edge unused-variable rule found three test values that were never checked: the + null-values batch test now asserts validation passed, the custom-rule message test asserts the + rule object survives `RuleSet::compile()`, and the `BadMethodCallException` test asserts the + caught exception is `UnknownFluentRuleMethod`. Clean under both 2.2.17 and 2.3.0. ### Changed diff --git a/tests/BatchDatabaseValidationTest.php b/tests/BatchDatabaseValidationTest.php index a732bd1..a7248bb 100644 --- a/tests/BatchDatabaseValidationTest.php +++ b/tests/BatchDatabaseValidationTest.php @@ -447,7 +447,7 @@ function setupTestDatabase(): void DB::connection('testing')->enableQueryLog(); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); @@ -478,7 +478,7 @@ function setupTestDatabase(): void ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->keys())->toContain('items.1.email') @@ -507,7 +507,7 @@ function setupTestDatabase(): void ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // Should fail — email doesn't exist in DB expect($validator->passes())->toBeFalse() @@ -535,7 +535,7 @@ function setupTestDatabase(): void ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); }); @@ -564,7 +564,7 @@ function setupTestDatabase(): void DB::connection('testing')->enableQueryLog(); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); @@ -601,7 +601,7 @@ function setupTestDatabase(): void ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // carol is soft-deleted, so this should fail expect($validator->passes())->toBeFalse() @@ -744,6 +744,10 @@ function setupTestDatabase(): void $errors = $validationException->errors(); } + // Every item is nullable and null, so validation passes + expect($errors) + ->toBeEmpty(); + $queryLog = DB::connection('testing')->getQueryLog(); DB::connection('testing')->disableQueryLog(); diff --git a/tests/BatchValidationGuardsTest.php b/tests/BatchValidationGuardsTest.php index 6d502f0..c95fd53 100644 --- a/tests/BatchValidationGuardsTest.php +++ b/tests/BatchValidationGuardsTest.php @@ -213,7 +213,7 @@ public function __toString(): string DB::connection('testing')->enableQueryLog(); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); $validator->passes(); $queryLog = DB::connection('testing')->getQueryLog(); @@ -257,7 +257,7 @@ public function __toString(): string DB::connection('testing')->enableQueryLog(); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); $validator->passes(); $queryLog = DB::connection('testing')->getQueryLog(); @@ -297,7 +297,7 @@ public function __toString(): string DB::connection('testing')->enableQueryLog(); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); $validator->passes(); $queryLog = DB::connection('testing')->getQueryLog(); @@ -543,7 +543,7 @@ public function __toString(): string $caught = null; try { - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $e) { $caught = $e; } @@ -737,7 +737,7 @@ public function __toString(): string $caught = null; try { $factory = resolve(Factory::class); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $validationException) { $caught = $validationException; } @@ -777,7 +777,7 @@ public function __toString(): string DB::connection('testing')->enableQueryLog(); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); @@ -822,7 +822,7 @@ public function __toString(): string $caught = null; try { $factory = resolve(Factory::class); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $validationException) { $caught = $validationException; } @@ -860,7 +860,7 @@ public function __toString(): string $caught = null; try { $factory = resolve(Factory::class); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $validationException) { $caught = $validationException; } @@ -900,7 +900,7 @@ public function __toString(): string $caught = null; try { $factory = resolve(Factory::class); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $validationException) { $caught = $validationException; } @@ -1039,7 +1039,7 @@ protected function failedValidation(Validator $validator): void $caught = null; try { $factory = resolve(Factory::class); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $validationException) { $caught = $validationException; } @@ -1202,7 +1202,7 @@ protected function failedValidation(Validator $validator): void $caught = null; try { $factory = resolve(Factory::class); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); } catch (ValidationException $e) { $caught = $e; } diff --git a/tests/Exceptions/UnknownFluentRuleMethodTest.php b/tests/Exceptions/UnknownFluentRuleMethodTest.php index 412458f..8742a76 100644 --- a/tests/Exceptions/UnknownFluentRuleMethodTest.php +++ b/tests/Exceptions/UnknownFluentRuleMethodTest.php @@ -16,15 +16,13 @@ })->throws(UnknownFluentRuleMethod::class); it('extends BadMethodCallException so existing catches keep working', function (): void { - $caught = null; - try { throw UnknownFluentRuleMethod::on('min'); } catch (BadMethodCallException $badMethodCallException) { $caught = $badMethodCallException; } - expect($caught)->not->toBeNull(); + expect($caught)->toBeInstanceOf(UnknownFluentRuleMethod::class); }); it('names the called method in the message', function (): void { diff --git a/tests/ExpandsWildcardsTest.php b/tests/ExpandsWildcardsTest.php index ebd15fc..71b4b05 100644 --- a/tests/ExpandsWildcardsTest.php +++ b/tests/ExpandsWildcardsTest.php @@ -28,7 +28,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated())->toHaveKeys(['items']); @@ -49,7 +49,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->keys())->toContain('items.0.name'); @@ -72,7 +72,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); return response()->json($validator->validated()); }); @@ -100,10 +100,10 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); if ($validator->fails()) { - throw new ValidationException($validator); // @phpstan-ignore argument.type + throw new ValidationException($validator); } return response()->json($validator->validated()); @@ -136,7 +136,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated())->toHaveKeys(['title', 'items']); @@ -160,7 +160,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); }); diff --git a/tests/FluentFormRequestTest.php b/tests/FluentFormRequestTest.php index e27b425..e6aae0f 100644 --- a/tests/FluentFormRequestTest.php +++ b/tests/FluentFormRequestTest.php @@ -178,7 +178,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // The fast check correctly identifies 'Jo' as failing (len 2 < min 5). // The bug was: after the fast check stored false, isset(false) returned true, @@ -207,8 +207,7 @@ ); $factory = resolve(Factory::class); - /** @var Validator $validator */ - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator)->toBeInstanceOf(OptimizedValidator::class) ->and($validator->passes())->toBeTrue() @@ -232,7 +231,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated()['items'])->toHaveCount(2); @@ -254,7 +253,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->keys())->toContain('items.0.name')->not->toContain('items.1.name'); @@ -277,7 +276,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated())->toHaveKeys(['title', 'items']); @@ -299,7 +298,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('items.1.email'))->toBeTrue(); @@ -322,7 +321,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->first('items.0.name'))->toBe('Name is required'); @@ -346,7 +345,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); @@ -371,7 +370,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); $afterCalled = false; $validator->after(function () use (&$afterCalled): void { @@ -397,7 +396,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); $validator->after(function (Validator $v): void { $v->errors()->add('items', 'Custom cross-field error from after hook'); @@ -425,7 +424,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('items.1.end'))->toBeTrue(); @@ -447,8 +446,7 @@ ); $factory = resolve(Factory::class); - /** @var Validator $validator */ - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // No wildcard rules — returns a plain Validator, not OptimizedValidator. expect($validator)->not->toBeInstanceOf(OptimizedValidator::class); @@ -473,7 +471,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // Empty array with no 'required' on parent — passes. No fast checks invoked. expect($validator->passes())->toBeTrue(); @@ -492,7 +490,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // Laravel's required rule fails on empty arrays (count < 1). expect($validator->passes())->toBeFalse(); @@ -514,7 +512,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // Items is not required, so missing is fine. No fast checks invoked. expect($validator->passes())->toBeTrue(); @@ -542,7 +540,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // First call — should pass. expect($validator->passes())->toBeTrue(); @@ -567,7 +565,7 @@ $factory = resolve(Factory::class); /** @var OptimizedValidator $validator */ - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); @@ -593,7 +591,7 @@ $factory = resolve(Factory::class); /** @var OptimizedValidator $validator */ - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('items.0.name'))->toBeTrue(); @@ -623,7 +621,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse(); // With bail, only one error should be reported (not both required and min:3). @@ -651,7 +649,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('items.0.user_id'))->toBeTrue(); @@ -685,7 +683,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); // All bail rules passed — closure should be called with the value. @@ -716,7 +714,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse(); // Closure ran and added a custom error. @@ -754,7 +752,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->keys())->not->toContain('users.0.name')->not->toContain('users.1.name') @@ -779,7 +777,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated()['tags'])->toHaveCount(3); @@ -798,7 +796,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('tags.1'))->toBeTrue() @@ -828,7 +826,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); }); @@ -851,7 +849,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('orders.0.items.1.qty'))->toBeTrue(); @@ -878,7 +876,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse(); // names are valid (fast-checked), email on item 1 fails (Laravel fallback) @@ -907,7 +905,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); $validator->stopOnFirstFailure(); expect($validator->passes())->toBeFalse(); @@ -936,7 +934,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated()['address']['street'])->toBe('123 Main St'); @@ -959,7 +957,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('address.street'))->toBeTrue(); @@ -988,7 +986,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // Add a dynamic rule via sometimes(). $validator->sometimes('items.*.isbn', 'required|string|min:10', fn (Fluent $input, Fluent $item): bool => $item->type === 'book'); @@ -1065,7 +1063,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse() ->and($validator->errors()->has('items.37.name'))->toBeTrue() @@ -1094,7 +1092,7 @@ data: ['items' => [['name' => 'Test']]], ); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); // The factory's resolver must be untouched — no swap happened. $resolverAfter = $resolverProp->getValue($factory); @@ -1121,7 +1119,7 @@ ); /** @var OptimizedValidator $validator */ - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); }); @@ -1139,7 +1137,7 @@ ); /** @var OptimizedValidator $validator */ - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); // Container should be set (needed for class-based rule extensions). $containerProp = new ReflectionProperty(Validator::class, 'container'); @@ -1176,7 +1174,7 @@ data: ['items' => [['name' => 'Test']]], ); - (fn () => $this->createDefaultValidator($factory))->call($formRequest); + defaultValidatorFor($formRequest, $factory); // The custom resolver must still be active — not replaced or cleared. $resolverProp = new ReflectionProperty(Illuminate\Validation\Factory::class, 'resolver'); @@ -1208,7 +1206,7 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); return response()->json($validator->validated()); }); @@ -1236,10 +1234,10 @@ ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); if ($validator->fails()) { - throw new ValidationException($validator); // @phpstan-ignore argument.type + throw new ValidationException($validator); } return response()->json($validator->validated()); @@ -1285,7 +1283,7 @@ public function authorize(): bool $instance->setRedirector(resolve(Redirector::class)); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($instance); + $validator = defaultValidatorFor($instance, $factory); expect($validator->passes())->toBeFalse(); // With stopOnFirstFailure, only the first failing field should have errors @@ -1302,7 +1300,7 @@ public function authorize(): bool ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeFalse(); // Without stopOnFirstFailure, both fields should have errors @@ -1343,7 +1341,7 @@ public function authorize(): bool $instance->setRedirector(resolve(Redirector::class)); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($instance); + $validator = defaultValidatorFor($instance, $factory); expect($validator->passes())->toBeTrue() ->and($validator->validated())->toBe(['name' => 'Ada']); @@ -1356,7 +1354,7 @@ public function authorize(): bool ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); }); @@ -1383,7 +1381,7 @@ public function authorize(): bool $instance->setRedirector(resolve(Redirector::class)); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($instance); + $validator = defaultValidatorFor($instance, $factory); expect($validator->passes())->toBeTrue(); }); diff --git a/tests/FluentRuleTest.php b/tests/FluentRuleTest.php index 56b662a..0e28cd3 100644 --- a/tests/FluentRuleTest.php +++ b/tests/FluentRuleTest.php @@ -558,6 +558,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $stringRule = FluentRule::string()->rule($customRule)->message('Custom message!'); $compiled = RuleSet::compile(['field' => $stringRule]); + // The custom rule object survives compilation as-is + expect($compiled['field'])->toContain($customRule); + // The message is keyed by the class basename [$messages] = RuleSet::extractMetadata(['field' => $stringRule]); $key = array_key_first($messages); diff --git a/tests/Pest.php b/tests/Pest.php index 920749c..cb308d8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,6 +11,7 @@ use Simtabi\Laranail\Validation\FluentSchema; use Simtabi\Laranail\Validation\HasFluentRules; use Simtabi\Laranail\Validation\Tests\TestCase; +use Illuminate\Contracts\Validation\Factory as ValidationFactory; uses(TestCase::class)->in(__DIR__); @@ -112,3 +113,28 @@ function bootFormRequest(FormRequest $formRequest, array $data): FormRequest return $instance; } + +/** + * Build the validator a FormRequest creates for itself, by running its + * protected createDefaultValidator() -- the method HasFluentRules overrides. + * + * Bound to the request, the closure takes FormRequest's declared return type, + * the Validator contract, which has no passes(). HasFluentRules returns the + * concrete Illuminate validator; this asserts that and returns it typed, so + * the call sites need no per-line narrowing. + */ +function defaultValidatorFor(FormRequest $formRequest, ValidationFactory $factory): Validator +{ + $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + + if (! $validator instanceof Validator) { + throw new LogicException(sprintf( + '%s::createDefaultValidator() returned %s, expected %s.', + $formRequest::class, + get_debug_type($validator), + Validator::class, + )); + } + + return $validator; +} diff --git a/tests/RuleSetTest.php b/tests/RuleSetTest.php index 526b905..50ab2ca 100644 --- a/tests/RuleSetTest.php +++ b/tests/RuleSetTest.php @@ -1557,7 +1557,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ); $factory = resolve(Factory::class); - $validator = (fn () => $this->createDefaultValidator($factory))->call($formRequest); + $validator = defaultValidatorFor($formRequest, $factory); expect($validator->passes())->toBeTrue(); });