Skip to content

Commit ef0c5b1

Browse files
Goosterhofclaude
andcommitted
fix: NEON double-backslash no-op in formRequestBaseClass + resourceDataBaseClass defaults
The shipped extension.neon parameter defaults used double-backslash single-quoted FQCNs. NEON only unescapes \\ inside double-quoted strings; in single quotes the backslashes stay literal, so the value decoded to a class name matching nothing and ClassReflection::isSubclassOf() returned false for every analysed class — both EnforceFormRequestToDtoRule and (pre-existing, since PR #20) EnforceResourceDataValidatorOptInRule were silent no-ops for any consumer using the default. CI stayed green because every test constructs the rule directly via the PHP ::class constructor default, never exercising the NEON registration path. Fixes (PR #33 review — jasperboerhof BLOCKER + MAJOR + MINOR, General-review concerns): - extension.neon: single-backslash defaults + inline NEON-quoting warning. - container-resolved regression test per rule (getAdditionalConfigFiles + getByType) pinning the NEON default and %parameter% wiring; both confirmed to fail when the double-backslash defect is reintroduced. - TraitProvidedToDtoRequest fixture + test (trait-provided toDto() satisfies the contract via hasNativeMethod trait flattening). - TransitiveViolatorRequest fixture + test (concrete leaf extends abstract base without toDto() — transitive omission must fire at the leaf). Gates: composer test (107), phpstan, format:check, coverage (88.06% >= 83) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1db6822 commit ef0c5b1

6 files changed

Lines changed: 194 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
1818

1919
- `EnforceCurrentUserAttributeRule` — corrected the controller-detection gate from an ancestry check (`ClassReflection::isSubclassOf(Illuminate\Routing\Controller)`) to a namespace prefix check (`$scope->getNamespace()` starts with `App\Http\Controllers`). The ancestry gate was a **silent no-op**: every consumer territory (kendo, ublgenie, entreezuil) ships base-less `final` controllers with no `extends Controller`, so the ancestry walk matched **zero** controllers and the rule enforced nothing. The namespace gate mirrors the sibling `ForbidEloquentMutationInControllersRule` and the canonical "controllers are identified by the `App\Http\Controllers` namespace" convention. Caught by the agent-review sweep on PR #26 (review `pullrequestreview-4401182606`). Regression-proofed by a base-less `final` controller fixture (`RequestUserInBaselessController` — flagged; `CurrentUserAttributeInBaselessController` — clean) that reproduces the exact real-world shape the rule missed. **Versioning:** false-negative closure — the rule now actually fires where it always intended to; this is part of the same `[Unreleased]` Major bump the rule's addition already carries (consumers with un-migrated controllers will now see the errors). No new identifier; no consumer `ignoreErrors` migration shape change.
2020

21+
- `EnforceFormRequestToDtoRule` / `EnforceResourceDataValidatorOptInRule` — corrected the shipped `extension.neon` parameter defaults from double-backslash single-quoted FQCNs (`'Illuminate\\Foundation\\Http\\FormRequest'`, `'App\\Http\\Resources\\ResourceData'`) to single-backslash (`'Illuminate\Foundation\Http\FormRequest'`, `'App\Http\Resources\ResourceData'`). NEON only unescapes `\\` inside **double**-quoted strings; in single quotes (and unquoted) `\\` stays two literal characters, so the parameter decoded to a 4-segment double-backslash class name that matches no real class. The effect: `ClassReflection::isSubclassOf()` returned false for every analysed class and **both rules were silent no-ops for any consumer registering `extension.neon` without an explicit parameter override** — CI stayed green because every PHPUnit test, the coverage gate, and Infection construct the rule directly via the PHP `::class` constructor default (PHP single-quoted strings *do* collapse `\\`), never exercising the NEON registration path. Verified empirically end-to-end: shipped default → `[OK] No errors` on the `ViolatorRequest` / `ViolatorResource` fixtures; single-backslash → fires with the exact expected message/line. The `resourceDataBaseClass` defect is pre-existing (shipped in PR #20, v0.3.0) — `EnforceResourceDataValidatorOptInRule` has been a no-op for default-configured consumers since release; fixed here in the same edit rather than deferred since `EnforceFormRequestToDtoRule` introduced the identical defect on the line above. Caught by the agent-review sweep on PR #33 (jasperboerhof BLOCKER). Regression-proofed by a container-resolved test per rule (`testRuleResolvesFromExtensionNeonAndFires` — resolves the rule from the PHPStan container via `getAdditionalConfigFiles()` + `getByType()`, exercising the NEON default and `%parameter%` wiring, then asserts the violator fires; both tests confirmed to fail when the double-backslash defect is reintroduced). An inline NEON-quoting warning comment now guards both defaults in `extension.neon`. **Versioning:** for `EnforceFormRequestToDtoRule`, this is part of the same `[Unreleased]` candidate-Major the rule's addition carries (the rule now actually fires in consumers). For `EnforceResourceDataValidatorOptInRule`, the v0.3.0 release that shipped the rule was a no-op for default consumers; restoring enforcement surfaces previously-undetected violations on `^0.3` consumers without a parameter override — **the pre-cascade audit demanded for both rules must now treat the resource-data rule as effectively un-enforced until this fix ships.**
22+
23+
### Added
24+
25+
- **Tests:** `EnforceFormRequestToDtoRule` gains two fixture-backed coverage additions closing documented-but-unpinned semantics: `TraitProvidedToDtoRequest` (a concrete request whose `toDto()` arrives via a trait — pins the trait leg of the `method_exists()` parity promise, which routes through PHPStan's `hasNativeMethod()` trait flattening) and `TransitiveViolatorRequest` (a concrete leaf extending the abstract `AbstractBaseRequest` with no `toDto()` anywhere in the chain — pins transitive-violation detection through an intermediate abstract layer, the inverse of the existing inherited-compliant case). Both raised in the PR #33 review (jasperboerhof MINOR + General-review concern). Test-only; no consumer-facing surface. **Versioning:** none (test coverage).
26+
2127
### Changed
2228

2329
- **CI:** pinned `symfony/console` to `^7.2` in `require-dev`. `symfony/console` 8.x (v8.1.0, released 2026-05-29) breaks Infection 0.33.x's mutation runner — its DI container references `Symfony\Component\Console\Helper\QuestionHelper` as a service Symfony Console 8 no longer registers that way, so `composer mutation:ci` aborts with `Unknown service` and exits 1. Because the package's `composer.lock` is gitignored, CI resolves dependencies fresh on every run; `illuminate/*` v13 permits Symfony 8, so the resolver began pulling v8.1.0 and the mutation gate went red fleet-wide (PRs green on 2026-05-28 turned red on 2026-05-29 with no source change). The pin holds the dev toolchain at `symfony/console` v7.4.x — verified mutation gate green (Covered Code MSI 81% ≥ 75) — until Infection ships Symfony Console 8 support, at which point this constraint should be widened or removed. **Versioning:** none (dev-only test-infra; no consumer-facing surface).

extension.neon

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ parameters:
33
# base class. Default matches the kendo / emmie / ublgenie / entreezuil
44
# convention `App\Http\Resources\ResourceData`. Override per consumer
55
# `phpstan.neon` if a territory ships the base under a different FQCN.
6-
resourceDataBaseClass: 'App\\Http\\Resources\\ResourceData'
6+
# NOTE: single backslashes — NEON only unescapes `\\` inside DOUBLE quotes;
7+
# in single quotes (and unquoted) `\\` stays two literal characters, which
8+
# decodes to a class name that matches nothing and silently no-ops the rule.
9+
resourceDataBaseClass: 'App\Http\Resources\ResourceData'
710

811
# `EnforceFormRequestToDtoRule`: FQCN of the FormRequest base class.
912
# Default matches the Laravel framework base every territory's requests
1013
# ultimately extend. Override per consumer `phpstan.neon` to narrow the
11-
# contract to a territory-local base FQCN.
12-
formRequestBaseClass: 'Illuminate\\Foundation\\Http\\FormRequest'
14+
# contract to a territory-local base FQCN. Single backslashes — see the
15+
# NEON-quoting note above.
16+
formRequestBaseClass: 'Illuminate\Foundation\Http\FormRequest'
1317

1418
parametersSchema:
1519
resourceDataBaseClass: string()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
use App\DataTransferObjects\StoreUserData;
8+
use Illuminate\Foundation\Http\FormRequest;
9+
10+
// Trait-provided toDto() must satisfy the contract — the rule routes through
11+
// PHPStan's hasNativeMethod(), which flattens trait-composed methods, mirroring
12+
// the source-of-truth Pest test's method_exists() matcher. Pins the trait leg
13+
// of the promise documented in the rule docblock, README, and CHANGELOG, which
14+
// otherwise rests on an untested assumption about PHPStan internals.
15+
trait ProvidesToDto
16+
{
17+
public function toDto(): StoreUserData
18+
{
19+
/** @var string $name */
20+
$name = $this->validated()['name'];
21+
22+
return new StoreUserData($name);
23+
}
24+
}
25+
26+
final class TraitProvidedToDtoRequest extends FormRequest
27+
{
28+
use ProvidesToDto;
29+
30+
/**
31+
* @return array<string, mixed>
32+
*/
33+
public function rules(): array
34+
{
35+
return [
36+
'name' => ['required', 'string'],
37+
];
38+
}
39+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace App\Http\Requests;
6+
7+
// Concrete leaf extending the abstract intermediate `AbstractBaseRequest`,
8+
// which declares no toDto() anywhere in the chain. Transitive-violation
9+
// detection must fire HERE, at the leaf — the inverse of
10+
// CompliantInheritedToDtoRequest (where the abstract parent supplies toDto()).
11+
// Proves the FQCN ancestor traversal detects omission through an intermediate
12+
// abstract layer, not only direct framework-base extension.
13+
final class TransitiveViolatorRequest extends AbstractBaseRequest
14+
{
15+
/**
16+
* @return array<string, mixed>
17+
*/
18+
public function rules(): array
19+
{
20+
return [
21+
'name' => ['required', 'string'],
22+
];
23+
}
24+
}

tests/Rules/EnforceFormRequestToDtoRuleTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,71 @@ public function testCompliantInheritedToDtoIsNotFlagged(): void
6161
);
6262
}
6363

64+
public function testTraitProvidedToDtoIsNotFlagged(): void
65+
{
66+
// A concrete request whose toDto() arrives via a trait. The rule
67+
// routes through PHPStan's hasNativeMethod(), which flattens
68+
// trait-composed methods — pins the trait leg of the contract that
69+
// the docblock, README, and CHANGELOG all promise but no fixture
70+
// previously exercised.
71+
$this->analyse(
72+
[
73+
__DIR__ . '/../Fixtures/FormRequestToDto/_stubs.php',
74+
__DIR__ . '/../Fixtures/FormRequestToDto/TraitProvidedToDtoRequest.php',
75+
],
76+
[],
77+
);
78+
}
79+
80+
public function testConcreteLeafExtendingAbstractBaseWithoutToDtoIsFlagged(): void
81+
{
82+
// The inverse of testCompliantInheritedToDtoIsNotFlagged: a concrete
83+
// leaf extends the abstract intermediate `AbstractBaseRequest`, which
84+
// declares no toDto() anywhere in the chain. Transitive-violation
85+
// detection must fire at the leaf — proving the ancestor traversal
86+
// catches omission through an intermediate abstract layer, not only
87+
// direct framework-base extension.
88+
$this->analyse(
89+
[
90+
__DIR__ . '/../Fixtures/FormRequestToDto/_stubs.php',
91+
__DIR__ . '/../Fixtures/FormRequestToDto/AbstractBaseRequest.php',
92+
__DIR__ . '/../Fixtures/FormRequestToDto/TransitiveViolatorRequest.php',
93+
],
94+
[
95+
[
96+
'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).',
97+
13,
98+
],
99+
],
100+
);
101+
}
102+
103+
public function testRuleResolvesFromExtensionNeonAndFires(): void
104+
{
105+
// End-to-end pin on the extension.neon registration path consumers
106+
// actually use: resolve the rule from the PHPStan container so the
107+
// shipped `formRequestBaseClass` default and the `%formRequestBaseClass%`
108+
// argument wiring are exercised — NOT the PHP constructor default.
109+
// A NEON quoting regression in the shipped default (e.g. the
110+
// double-backslash single-quoted form) silently no-ops the rule while
111+
// every direct-instantiation test stays green; this is the only gate
112+
// that catches it.
113+
$this->ruleOverride = self::getContainer()->getByType(EnforceFormRequestToDtoRule::class);
114+
115+
$this->analyse(
116+
[
117+
__DIR__ . '/../Fixtures/FormRequestToDto/_stubs.php',
118+
__DIR__ . '/../Fixtures/FormRequestToDto/ViolatorRequest.php',
119+
],
120+
[
121+
[
122+
'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).',
123+
9,
124+
],
125+
],
126+
);
127+
}
128+
64129
public function testAbstractBaseWithoutToDtoIsNotFlagged(): void
65130
{
66131
// The per-territory `BaseFormRequest` shape: abstract, extends the
@@ -104,6 +169,20 @@ public function testCustomBaseClassParameterMatchesAlternativeFqcn(): void
104169
);
105170
}
106171

172+
/**
173+
* Load the shipped extension.neon so testRuleResolvesFromExtensionNeonAndFires
174+
* can pull the rule out of the container with its NEON-configured
175+
* `formRequestBaseClass` parameter applied.
176+
*
177+
* @return array<int, string>
178+
*/
179+
public static function getAdditionalConfigFiles(): array
180+
{
181+
return [
182+
__DIR__ . '/../../extension.neon',
183+
];
184+
}
185+
107186
protected function getRule(): Rule
108187
{
109188
return $this->ruleOverride ?? new EnforceFormRequestToDtoRule;

tests/Rules/EnforceResourceDataValidatorOptInRuleTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,45 @@ public function testCustomBaseClassParameterMatchesAlternativeFqcn(): void
136136
);
137137
}
138138

139+
public function testRuleResolvesFromExtensionNeonAndFires(): void
140+
{
141+
// End-to-end pin on the extension.neon registration path consumers
142+
// actually use: resolve the rule from the PHPStan container so the
143+
// shipped `resourceDataBaseClass` default and the
144+
// `%resourceDataBaseClass%` argument wiring are exercised — NOT the PHP
145+
// constructor default. The shipped default carried a NEON
146+
// double-backslash quoting defect that silently no-op'd this rule for
147+
// every default consumer since PR #20; this gate catches it.
148+
$this->ruleOverride = self::getContainer()->getByType(EnforceResourceDataValidatorOptInRule::class);
149+
150+
$this->analyse(
151+
[
152+
__DIR__ . '/../Fixtures/ResourceDataValidatorOptIn/_stubs.php',
153+
__DIR__ . '/../Fixtures/ResourceDataValidatorOptIn/ViolatorResource.php',
154+
],
155+
[
156+
[
157+
'App\Http\Resources\ViolatorResource declares EAGER_LOAD_COUNT but does not call validateRelationsLoaded() — silent-zero bug risk (ADR-0009 / war-room queue #55 / kendo PR #1084 opt-in invariant).',
158+
9,
159+
],
160+
],
161+
);
162+
}
163+
164+
/**
165+
* Load the shipped extension.neon so testRuleResolvesFromExtensionNeonAndFires
166+
* can pull the rule out of the container with its NEON-configured
167+
* `resourceDataBaseClass` parameter applied.
168+
*
169+
* @return array<int, string>
170+
*/
171+
public static function getAdditionalConfigFiles(): array
172+
{
173+
return [
174+
__DIR__ . '/../../extension.neon',
175+
];
176+
}
177+
139178
protected function getRule(): Rule
140179
{
141180
return $this->ruleOverride ?? new EnforceResourceDataValidatorOptInRule;

0 commit comments

Comments
 (0)