diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2b1f8..e72680c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +## [0.6.1] — 2026-07-01 + +**Release-as-a-whole: PATCH (false-positive narrowing — `^0.6` consumers inherit on a plain `composer update`, no pin bump).** Removes errors from previously-flagged code; adds none. Not a candidate-major. + +### Fixed + +- `EnforceFormRequestToDtoRule` — now accepts the plural `toDtos()` bulk-list convention, not only the singular `toDto()`. The rule checked a single `DTO_METHOD_NAME = 'toDto'` and false-positived on every FormRequest that converts its validated input to a `list<…Data>` via `toDtos()` — the canonical ADR-0020 bulk-reorder pattern (e.g. codebook's 5 bulk requests, kendo's `ReorderEpicsRequest`). A concrete FormRequest that declares or inherits **`toDto()` OR `toDtos()`** now satisfies the contract; only a class with **neither** is flagged (`DTO_METHOD_NAME` → `DTO_METHOD_NAMES = ['toDto', 'toDtos']`, pass if `hasNativeMethod()` is true for any). This realigns the Level-2 PHPStan rule with the Level-1 source-of-truth arch test (`FormRequestsTest`), which already accepts `toDtos()` and is green — the rule was stricter than the doctrine it encodes, contradicting the arch test it mirrors. The abstract-skip gate, the FormRequest-base inheritance gate, and the `formRequestToDtoExemptClasses` exemption param are unchanged. Error message updated to read `toDto()/toDtos()`. Regression-pinned by a new `CompliantToDtosRequest` fixture (defines only `toDtos(): array`, must not flag) alongside the existing neither-method positive case. **Versioning: PATCH per ADR-0021 §Versioning (false-positive narrowing — removes errors, adds none).** Seed: fleet survey 2026-07-01 (`origin/development`) — codebook (5 requests) + kendo (`ReorderEpicsRequest`, 1) define `toDtos()`; both were about to suppress this false positive per-consumer (the wrong level). + ## [0.6.0] — 2026-07-01 **Release-as-a-whole: backward-compatible MINOR** — one new optional PHPStan parameter (`formRequestToDtoExemptClasses`, default `[]`) on `EnforceFormRequestToDtoRule`. Default-empty ⇒ zero new errors in existing consumers; a consumer adopts on its own `^0.5 → ^0.6` pin bump. Seed: war-room enforcement queue #131 — a class-keyed exemption surface so a territory can retire a duplicate local `FormRequestsTest` arch test and consolidate its exempt list into package config. diff --git a/src/Rules/EnforceFormRequestToDtoRule.php b/src/Rules/EnforceFormRequestToDtoRule.php index 1ebbec8..35da9be 100644 --- a/src/Rules/EnforceFormRequestToDtoRule.php +++ b/src/Rules/EnforceFormRequestToDtoRule.php @@ -18,10 +18,12 @@ /** * 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. + * subclass must define (or inherit) a `toDto()` method — or its ADR-0020 + * bulk-list sibling `toDtos()` (one request → `list<…Data>`) — so validated + * input crosses the HTTP boundary as a typed DTO, never as a raw validated + * array. Without either 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 @@ -41,10 +43,13 @@ * 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). + * 3. Class neither declares nor inherits a `toDto()` OR `toDtos()` + * method — own declarations, parent-class declarations, and + * trait-provided methods of EITHER name all satisfy the contract + * (mirroring the source-of-truth Pest test's `method_exists()` matcher, + * which already accepts both). `toDtos()` is the ADR-0020 bulk-list + * convention (bulk-reorder requests convert to `list<…Data>`); the + * singular check alone false-positived on every such request. * * Legitimately DTO-less requests (entreezuil precedent: `LoginRequest`, * whose auth flow calls `AuthManager::attempt()` directly) are suppressed @@ -74,7 +79,18 @@ */ final class EnforceFormRequestToDtoRule implements Rule { - private const string DTO_METHOD_NAME = 'toDto'; + /** + * The DTO-handoff contract is satisfied by either the singular `toDto()` + * (one request → one typed DTO) or the plural `toDtos()` (ADR-0020 + * bulk-list pattern — one request → `list<…Data>`, e.g. a bulk-reorder + * request). A class declaring or inheriting EITHER satisfies the + * contract; only a class with NEITHER is flagged. Mirrors the + * source-of-truth Pest test (`FormRequestsTest`) which already accepts + * both method names. + * + * @var list + */ + private const array DTO_METHOD_NAMES = ['toDto', 'toDtos']; /** * @param list $exemptClasses fully-qualified class names to skip @@ -110,8 +126,10 @@ public function processNode(Node $node, Scope $scope): array return []; } - if ($classReflection->hasNativeMethod(self::DTO_METHOD_NAME)) { - return []; + foreach (self::DTO_METHOD_NAMES as $method) { + if ($classReflection->hasNativeMethod($method)) { + return []; + } } // Class-keyed consumer exemption: exact-FQCN match against the @@ -124,7 +142,7 @@ public function processNode(Node $node, Scope $scope): array 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).', + '%s extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', $classReflection->getName(), )) ->identifier('enforceFormRequestToDto.missingToDtoMethod') diff --git a/tests/Fixtures/FormRequestToDto/CompliantToDtosRequest.php b/tests/Fixtures/FormRequestToDto/CompliantToDtosRequest.php new file mode 100644 index 0000000..0f47f3a --- /dev/null +++ b/tests/Fixtures/FormRequestToDto/CompliantToDtosRequest.php @@ -0,0 +1,42 @@ +` via `toDtos()` (plural) instead of a + * single `toDto()`. This is the canonical bulk-reorder shape — the rule must + * NOT flag it. The singular-only check false-positived on every request of + * this shape; this fixture pins the plural leg of the contract. + */ +final class CompliantToDtosRequest extends FormRequest +{ + /** + * @return array + */ + public function rules(): array + { + return [ + 'names' => ['required', 'array'], + ]; + } + + /** + * @return list + */ + public function toDtos(): array + { + /** @var list $names */ + $names = $this->validated()['names']; + + return array_map( + static fn(string $name): StoreUserData => new StoreUserData($name), + $names, + ); + } +} diff --git a/tests/Rules/EnforceFormRequestToDtoRuleTest.php b/tests/Rules/EnforceFormRequestToDtoRuleTest.php index 09d7702..05e7155 100644 --- a/tests/Rules/EnforceFormRequestToDtoRuleTest.php +++ b/tests/Rules/EnforceFormRequestToDtoRuleTest.php @@ -28,7 +28,7 @@ public function testViolatorWithoutToDtoIsFlagged(): void ], [ [ - 'App\Http\Requests\ViolatorRequest 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).', + 'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 9, ], ], @@ -46,6 +46,23 @@ public function testCompliantOwnToDtoIsNotFlagged(): void ); } + public function testCompliantToDtosBulkListIsNotFlagged(): void + { + // ADR-0020 bulk-list convention: the request defines only the plural + // `toDtos(): array` (one request → list<…Data>), no singular toDto(). + // The rule must NOT flag it — accepting either method name is the fix + // for the false positive against every bulk-reorder request, and it + // matches the source-of-truth FormRequestsTest arch test, which + // already accepts toDtos(). + $this->analyse( + [ + __DIR__ . '/../Fixtures/FormRequestToDto/_stubs.php', + __DIR__ . '/../Fixtures/FormRequestToDto/CompliantToDtosRequest.php', + ], + [], + ); + } + public function testCompliantInheritedToDtoIsNotFlagged(): void { // The abstract intermediate declares toDto(); the concrete leaf @@ -93,7 +110,7 @@ public function testConcreteLeafExtendingAbstractBaseWithoutToDtoIsFlagged(): vo ], [ [ - 'App\Http\Requests\TransitiveViolatorRequest 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).', + 'App\Http\Requests\TransitiveViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 13, ], ], @@ -119,7 +136,7 @@ public function testRuleResolvesFromExtensionNeonAndFires(): void ], [ [ - 'App\Http\Requests\ViolatorRequest 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).', + 'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 9, ], ], @@ -162,7 +179,7 @@ public function testCustomBaseClassParameterMatchesAlternativeFqcn(): void [__DIR__ . '/../Fixtures/FormRequestToDto/UnrelatedShortNameCollision.php'], [ [ - 'App\Unrelated\UnrelatedShortNameCollision 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).', + 'App\Unrelated\UnrelatedShortNameCollision extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 12, ], ], @@ -182,7 +199,7 @@ public function testViolatorWithEmptyExemptListIsFlagged(): void ], [ [ - 'App\Http\Requests\ViolatorRequest 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).', + 'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 9, ], ], @@ -223,7 +240,7 @@ public function testExemptionIsPreciseNotGlobalOffSwitch(): void ], [ [ - 'App\Http\Requests\SecondViolatorRequest 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).', + 'App\Http\Requests\SecondViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 12, ], ], @@ -246,7 +263,7 @@ public function testExemptMatchIsExactFqcnNotShortNameOrOtherNamespace(): void ], [ [ - 'App\Http\Requests\ViolatorRequest 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).', + 'App\Http\Requests\ViolatorRequest extends FormRequest but does not define a toDto()/toDtos() method — raw validated-array handoff risk (ADR-0012 / war-room queue #55 / entreezuil FormRequestsTest opt-in invariant).', 9, ], ],