-
Notifications
You must be signed in to change notification settings - Fork 0
engineer: add EnforceFormRequestToDtoRule (queue #55 instance 2, Phase-2 promotion) #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9d3bb72
engineer: add EnforceFormRequestToDtoRule (queue #55 instance 2, Phas…
Goosterhof 6d86850
docs: CHANGELOG + CLAUDE.md + README for EnforceFormRequestToDtoRule
Goosterhof 1db6822
Merge remote-tracking branch 'origin/main' into engineer/enforce-form…
jasperboerhof ef0c5b1
fix: NEON double-backslash no-op in formRequestBaseClass + resourceDa…
Goosterhof File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace ScriptDevelopment\PhpstanWarroomRules\Rules; | ||
|
|
||
| use Illuminate\Foundation\Http\FormRequest; | ||
| use PhpParser\Node; | ||
| use PhpParser\Node\Stmt\Class_; | ||
| use PHPStan\Analyser\Scope; | ||
| use PHPStan\Node\InClassNode; | ||
| use PHPStan\Reflection\ClassReflection; | ||
| use PHPStan\Rules\Rule; | ||
| use PHPStan\Rules\RuleErrorBuilder; | ||
|
|
||
| use function sprintf; | ||
|
|
||
| /** | ||
| * Enforces ADR-0012 §FormRequest → DTO Flow: every concrete `FormRequest` | ||
| * subclass must define (or inherit) a `toDto()` method so validated input | ||
| * crosses the HTTP boundary as a typed DTO, never as a raw validated array. | ||
| * Without the method, controllers hand `$request->validated()` arrays to | ||
| * Actions — untyped, key-renameable, and invisible to static analysis. | ||
| * | ||
| * Doctrine source: ADR-0012 (FormRequest → DTO Flow). Promoted from | ||
| * entreezuil's reflection-based Pest arch test | ||
| * (`tests/Arch/FormRequestsTest.php`, "form requests with mutation actions | ||
| * define toDto method") — the second instance of the "arch test detects | ||
| * misuse but not omission" enforcement shape under war-room enforcement | ||
| * queue #55, dispositioned for Phase-2 promotion by the Commander on | ||
| * 2026-05-07. Sister of `EnforceResourceDataValidatorOptInRule` (instance 3, | ||
| * PR #20). | ||
| * | ||
| * Scope: classes whose ancestor chain includes the configured base FQCN | ||
| * (default: `Illuminate\Foundation\Http\FormRequest`). Inheritance is | ||
| * matched via PHPStan reflection — short-name collisions in unrelated | ||
| * namespaces do not fire. Abstract classes are skipped (the per-territory | ||
| * `BaseFormRequest` shape is an intermediate layer, not a mutation request). | ||
| * | ||
| * Detection (all three must hold): | ||
| * 1. Class transitively extends the configured base class. | ||
| * 2. Class is concrete (abstract intermediates are exempt). | ||
| * 3. Class neither declares nor inherits a `toDto()` method — own | ||
| * declarations, parent-class declarations, and trait-provided methods | ||
| * all satisfy the contract (mirroring the source-of-truth Pest test's | ||
| * `method_exists()` matcher). | ||
| * | ||
| * Legitimately DTO-less requests (entreezuil precedent: `LoginRequest`, | ||
| * whose auth flow calls `AuthManager::attempt()` directly) are suppressed | ||
| * per consumer `phpstan.neon` `ignoreErrors` keyed on the identifier — | ||
| * never by name inside the rule, per the package convention. | ||
| * | ||
| * Implementation note: the constructor default uses `FormRequest::class` | ||
| * (compile-time constant, never autoloads) instead of an FQCN string | ||
| * literal. Pint's class_keyword fixer calls class_exists() on string | ||
| * literals that look like class names, and the Pint phar bundles a real | ||
| * `Illuminate\Foundation\Http\FormRequest` whose ValidatesWhenResolvedTrait | ||
| * dependency is NOT bundled — a bare FQCN string literal anywhere in this | ||
| * package's PHP source makes `composer format` fatal with "Trait not | ||
| * found". The `use` import is alias-only; consumers analysing non-Laravel | ||
| * trees are unaffected because `::class` resolution requires no autoload. | ||
| * | ||
| * @implements Rule<InClassNode> | ||
| */ | ||
| final class EnforceFormRequestToDtoRule implements Rule | ||
| { | ||
| private const string DTO_METHOD_NAME = 'toDto'; | ||
|
|
||
| public function __construct( | ||
| private string $formRequestBaseClass = FormRequest::class, | ||
| ) {} | ||
|
|
||
| public function getNodeType(): string | ||
| { | ||
| return InClassNode::class; | ||
| } | ||
|
|
||
| public function processNode(Node $node, Scope $scope): array | ||
| { | ||
| $classNode = $node->getOriginalNode(); | ||
|
|
||
| if (!$classNode instanceof Class_) { | ||
| return []; | ||
| } | ||
|
|
||
| if ($classNode->isAbstract()) { | ||
| return []; | ||
| } | ||
|
|
||
| $classReflection = $node->getClassReflection(); | ||
|
|
||
| if (!$this->extendsFormRequestBase($classReflection)) { | ||
| return []; | ||
| } | ||
|
|
||
| if ($classReflection->hasNativeMethod(self::DTO_METHOD_NAME)) { | ||
| return []; | ||
| } | ||
|
|
||
| return [ | ||
| RuleErrorBuilder::message(sprintf( | ||
| '%s extends FormRequest but does not define a toDto() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', | ||
| $classReflection->getName(), | ||
| )) | ||
| ->identifier('enforceFormRequestToDto.missingToDtoMethod') | ||
| ->line($classNode->getStartLine()) | ||
| ->build(), | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * Inheritance gate: the class must be a (transitive) subclass of the | ||
| * configured base FQCN. Uses PHPStan reflection — handles intermediate | ||
| * abstract layers and namespace-relative `extends` clauses. Short-name | ||
| * collisions in unrelated namespaces do not match, and the base class | ||
| * itself is not a subclass of itself. | ||
| */ | ||
| private function extendsFormRequestBase(ClassReflection $classReflection): bool | ||
| { | ||
| return $classReflection->isSubclassOf($this->formRequestBaseClass); | ||
|
Goosterhof marked this conversation as resolved.
|
||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use Illuminate\Foundation\Http\FormRequest; | ||
|
|
||
| // Per-territory abstract intermediate (the entreezuil `BaseFormRequest` | ||
| // shape) — not a mutation request, declares no toDto(), and must NOT fire: | ||
| // abstract classes are exempt from the contract. | ||
| abstract class AbstractBaseRequest extends FormRequest | ||
| { | ||
| public function authorize(): bool | ||
| { | ||
| return true; | ||
| } | ||
| } |
36 changes: 36 additions & 0 deletions
36
tests/Fixtures/FormRequestToDto/CompliantInheritedToDtoRequest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use App\DataTransferObjects\StoreUserData; | ||
| use Illuminate\Foundation\Http\FormRequest; | ||
|
|
||
| // The intermediate abstract layer declares toDto(); the concrete leaf | ||
| // inherits it. Both must stay clean: abstract classes are exempt, and | ||
| // inherited declarations satisfy the contract (mirroring the source-of-truth | ||
| // Pest test's method_exists() matcher). | ||
| abstract class RequestWithSharedDto extends FormRequest | ||
| { | ||
| public function toDto(): StoreUserData | ||
| { | ||
| /** @var string $name */ | ||
| $name = $this->validated()['name']; | ||
|
|
||
| return new StoreUserData($name); | ||
| } | ||
| } | ||
|
|
||
| final class CompliantInheritedToDtoRequest extends RequestWithSharedDto | ||
| { | ||
| /** | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'name' => ['required', 'string'], | ||
| ]; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use App\DataTransferObjects\StoreUserData; | ||
| use Illuminate\Foundation\Http\FormRequest; | ||
|
|
||
| final class CompliantToDtoRequest extends FormRequest | ||
| { | ||
| /** | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'name' => ['required', 'string'], | ||
| ]; | ||
| } | ||
|
|
||
| public function toDto(): StoreUserData | ||
| { | ||
| /** @var string $name */ | ||
| $name = $this->validated()['name']; | ||
|
|
||
| return new StoreUserData($name); | ||
| } | ||
| } |
39 changes: 39 additions & 0 deletions
39
tests/Fixtures/FormRequestToDto/TraitProvidedToDtoRequest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use App\DataTransferObjects\StoreUserData; | ||
| use Illuminate\Foundation\Http\FormRequest; | ||
|
|
||
| // Trait-provided toDto() must satisfy the contract — the rule routes through | ||
| // PHPStan's hasNativeMethod(), which flattens trait-composed methods, mirroring | ||
| // the source-of-truth Pest test's method_exists() matcher. Pins the trait leg | ||
| // of the promise documented in the rule docblock, README, and CHANGELOG, which | ||
| // otherwise rests on an untested assumption about PHPStan internals. | ||
| trait ProvidesToDto | ||
| { | ||
| public function toDto(): StoreUserData | ||
| { | ||
| /** @var string $name */ | ||
| $name = $this->validated()['name']; | ||
|
|
||
| return new StoreUserData($name); | ||
| } | ||
| } | ||
|
|
||
| final class TraitProvidedToDtoRequest extends FormRequest | ||
| { | ||
| use ProvidesToDto; | ||
|
|
||
| /** | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'name' => ['required', 'string'], | ||
| ]; | ||
| } | ||
| } |
24 changes: 24 additions & 0 deletions
24
tests/Fixtures/FormRequestToDto/TransitiveViolatorRequest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| // Concrete leaf extending the abstract intermediate `AbstractBaseRequest`, | ||
| // which declares no toDto() anywhere in the chain. Transitive-violation | ||
| // detection must fire HERE, at the leaf — the inverse of | ||
| // CompliantInheritedToDtoRequest (where the abstract parent supplies toDto()). | ||
| // Proves the FQCN ancestor traversal detects omission through an intermediate | ||
| // abstract layer, not only direct framework-base extension. | ||
| final class TransitiveViolatorRequest extends AbstractBaseRequest | ||
| { | ||
| /** | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'name' => ['required', 'string'], | ||
| ]; | ||
| } | ||
| } |
23 changes: 23 additions & 0 deletions
23
tests/Fixtures/FormRequestToDto/UnrelatedShortNameCollision.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| namespace App\Unrelated; | ||
|
|
||
| // A class with the short name `FormRequest` in an unrelated namespace MUST | ||
| // NOT be matched as the rule's base class. The detection uses the FQCN, not | ||
| // the short name. | ||
| abstract class FormRequest {} | ||
|
|
||
| final class UnrelatedShortNameCollision extends FormRequest | ||
| { | ||
| /** | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| 'name' => ['required', 'string'], | ||
| ]; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.