-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEnforceFormRequestToDtoRule.php
More file actions
147 lines (131 loc) · 6.16 KB
/
Copy pathEnforceFormRequestToDtoRule.php
File metadata and controls
147 lines (131 loc) · 6.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
<?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 in_array;
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
* one of two ways, both consumer-config-driven — never by name inside the
* rule, per the package convention:
* 1. Per-file `phpstan.neon` `ignoreErrors` keyed on the identifier +
* path (brittle to file moves).
* 2. The `formRequestToDtoExemptClasses` PHPStan parameter — a list of
* fully-qualified class names to skip (matched by exact FQCN). This is
* the class-keyed alternative to `ignoreErrors`, intended for porting a
* retiring local arch test's FQCN exemption list into package config
* 1:1. A consumer-supplied FQCN list is *config*, not a rule-body
* literal — no class name is ever hardcoded in this rule, so the
* "never by name inside the rule" convention is preserved.
*
* 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';
/**
* @param list<string> $exemptClasses fully-qualified class names to skip
* (exact-FQCN match); the class-keyed
* alternative to `ignoreErrors`,
* supplied only from consumer config
*/
public function __construct(
private string $formRequestBaseClass = FormRequest::class,
private array $exemptClasses = [],
) {}
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 [];
}
// Class-keyed consumer exemption: exact-FQCN match against the
// configured list. Predictable (no short-name collisions), and it
// ports a retiring local arch test's exempt-class list 1:1. Names
// live in consumer config, never in this rule body.
if (in_array($classReflection->getName(), $this->exemptClasses, true)) {
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);
}
}