From 0b9c350bc9b79e68316d9bf32e236374968c7a9d Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 22 May 2026 14:25:17 +0200 Subject: [PATCH 1/4] feat: add EnforceCurrentUserAttributeRule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags authenticated-user resolution via Request::user() / Auth::user() / auth()->user() inside controller methods. The canonical fix is Laravel's #[\Illuminate\Container\Attributes\CurrentUser] container attribute on the method parameter — eliminates the implicit-nullable-then-assert dance. Detection branches on three call shapes via CallLike registration: - MethodCall on Illuminate\Http\Request subtype receiver (type-based via ObjectType::isSuperTypeOf()) - MethodCall whose receiver is a FuncCall('auth') (AST-shape match — the helper's return type is unloaded in stub-only environments) - StaticCall resolving to Illuminate\Support\Facades\Auth (FQCN comparison via \$scope->resolveName()) Scoped to Controller descendants via ClassReflection::isSubclassOf( Illuminate\Routing\Controller). FormRequest descendants, middleware, services, Actions, jobs, and console commands are silent by design. Identifier: enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser Doctrine: war-room §Architectural Principles — Explicit over implicit Origin: war-room cross-territory recon 2026-05-22 Co-Authored-By: Claude Opus 4.7 (1M context) --- extension.neon | 3 + src/Rules/EnforceCurrentUserAttributeRule.php | 197 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/Rules/EnforceCurrentUserAttributeRule.php diff --git a/extension.neon b/extension.neon index 0034fb9..4c936b5 100644 --- a/extension.neon +++ b/extension.neon @@ -38,6 +38,9 @@ services: arguments: resourceDataBaseClass: %resourceDataBaseClass% tags: [phpstan.rules.rule] + - + class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceCurrentUserAttributeRule + tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Type\ConnectionTransactionReturnTypeExtension tags: [phpstan.broker.dynamicMethodReturnTypeExtension] diff --git a/src/Rules/EnforceCurrentUserAttributeRule.php b/src/Rules/EnforceCurrentUserAttributeRule.php new file mode 100644 index 0000000..c9f0271 --- /dev/null +++ b/src/Rules/EnforceCurrentUserAttributeRule.php @@ -0,0 +1,197 @@ +user() calls inside controller methods. + * Use the #[\Illuminate\Container\Attributes\CurrentUser] container attribute on the + * method parameter instead — eliminates the nullable-then-assert dance. + * + * Scoped to classes extending Illuminate\Routing\Controller. FormRequest descendants + * are excluded by design — container-attribute injection does not apply to + * FormRequest::rules() / toDto() / authorize() invocations. Middleware, services, + * Actions, jobs, and console commands are likewise out of scope: each context has + * its own canonical resolution path (constructor DI for Actions, authenticated + * payload threading for jobs, etc.). + * + * Detection (three call shapes branch in `processNode`): + * 1. `MethodCall` — receiver type is a (subtype of) `Illuminate\Http\Request` + * and method name is `user`. Type-based via `ObjectType::isSuperTypeOf()`, + * mirroring `EnforceAuditSnapshotOnRetryRule::receiverIsConnectionInterface()`. + * 2. `MethodCall` — receiver is a `FuncCall` to `auth()` and method name is + * `user`. AST-shape match (the helper has no class to type-check against + * in a stub-only fixture environment). + * 3. `StaticCall` — class name resolves to `Illuminate\Support\Facades\Auth` + * and method name is `user`. FQCN comparison via `$scope->resolveName()` + * handles aliased imports. + * + * Containing-class gate (applied to all three branches): walk + * `$scope->getClassReflection()` ancestry and fire only if the class extends + * `Illuminate\Routing\Controller`. Out-of-scope classes return silently. + * + * Implementation note: `getNodeType()` returns `CallLike::class` so the rule + * sees both `MethodCall` and `StaticCall` in a single registration — mirrors + * the `LogRule` v0.3.0 expansion shape. + * + * Doctrine source: war-room §Architectural Principles — Explicit over implicit. + * + * @implements Rule + */ +final class EnforceCurrentUserAttributeRule implements Rule +{ + private const string TARGET_METHOD = 'user'; + + private const string CONTROLLER_BASE_FQCN = 'Illuminate\Routing\Controller'; + + private const string REQUEST_FQCN = Request::class; + + private const string AUTH_FACADE_FQCN = Auth::class; + + public function getNodeType(): string + { + return CallLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->insideControllerMethod($scope)) { + return []; + } + + if ($node instanceof MethodCall) { + return $this->processMethodCall($node, $scope); + } + + if ($node instanceof StaticCall) { + return $this->processStaticCall($node, $scope); + } + + return []; + } + + /** + * Containing-class gate: the rule fires only inside methods of a class + * that extends `Illuminate\Routing\Controller`. FormRequest descendants, + * middleware, services, Actions, jobs, and console commands are silent + * because their class hierarchies do not include Controller. + */ + private function insideControllerMethod(Scope $scope): bool + { + $classReflection = $scope->getClassReflection(); + + if (!$classReflection instanceof ClassReflection) { + return false; + } + + if ($classReflection->getName() === self::CONTROLLER_BASE_FQCN) { + return false; + } + + return $classReflection->isSubclassOf(self::CONTROLLER_BASE_FQCN); + } + + /** + * @return list + */ + private function processMethodCall(MethodCall $node, Scope $scope): array + { + if (!$node->name instanceof Identifier || $node->name->toString() !== self::TARGET_METHOD) { + return []; + } + + // Shape 1: $request->user() — receiver type is Illuminate\Http\Request + if ($this->receiverIsRequest($node, $scope)) { + return [$this->buildError('$request->user()')]; + } + + // Shape 2: auth()->user() — receiver is FuncCall('auth') + if ($this->receiverIsAuthHelper($node)) { + return [$this->buildError('auth()->user()')]; + } + + return []; + } + + /** + * @return list + */ + private function processStaticCall(StaticCall $node, Scope $scope): array + { + if (!$node->name instanceof Identifier || $node->name->toString() !== self::TARGET_METHOD) { + return []; + } + + if (!$node->class instanceof Name) { + return []; + } + + if ($scope->resolveName($node->class) !== self::AUTH_FACADE_FQCN) { + return []; + } + + return [$this->buildError('Auth::user()')]; + } + + /** + * Type-based receiver gate for shape 1: `$scope->getType($node->var)` must + * be a (subtype of) `Illuminate\Http\Request`. Mirrors the canonical type + * pattern in `EnforceAuditSnapshotOnRetryRule::receiverIsConnectionInterface()`. + */ + private function receiverIsRequest(MethodCall $node, Scope $scope): bool + { + $receiverType = $scope->getType($node->var); + $requestType = new ObjectType(self::REQUEST_FQCN); + + return $requestType->isSuperTypeOf($receiverType)->yes(); + } + + /** + * AST-shape gate for shape 2: the receiver is a `FuncCall` to `auth()`. + * No type-based check because the `auth()` helper's return type + * (AuthManager / Guard) is not loaded in stub-only fixture environments; + * the AST shape is unambiguous on its own. + */ + private function receiverIsAuthHelper(MethodCall $node): bool + { + if (!$node->var instanceof FuncCall) { + return false; + } + + if (!$node->var->name instanceof Name) { + return false; + } + + return $node->var->name->toString() === 'auth'; + } + + private function buildError(string $callShape): IdentifierRuleError + { + return RuleErrorBuilder::message(sprintf( + 'Authenticated-user resolution in controller methods uses the #[CurrentUser] container attribute. ' + . 'Add `#[\Illuminate\Container\Attributes\CurrentUser] User $user` to the method signature instead of calling %s inside the body.', + $callShape, + )) + ->identifier('enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser') + ->build(); + } +} From 334aa5f290e7147a7486b8d7d2574f885b449cb3 Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 22 May 2026 14:25:33 +0200 Subject: [PATCH 2/4] test: cover EnforceCurrentUserAttributeRule fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 10 fixtures and 10 test methods covering every detection branch and every legitimate-skip case: Fire (4): - RequestUserInController — shape 1 (Request subtype receiver) - AuthFacadeInController — shape 3 (Auth::user() static call) - AuthHelperInController — shape 2 (auth() helper receiver) - RequestUserInControllerWithAssert — exact PR #263 shape; fires once on the \$request->user() call (assert is downstream noise) Silent (5): - UsesCurrentUserAttribute — already-canonical #[CurrentUser] adoption - RequestUserInFormRequest — FormRequest descendant, by-design exclusion - AuthInMiddleware — not a Controller descendant - AuthInAction — not a Controller descendant - AuthInJob — not a Controller descendant Mutation-killing: - TopLevelAuthCall — top-level call outside any class (kills FalseValue mutant on the null-class-reflection branch) The _stubs.php file provides bracketed-namespace stubs for Illuminate\Routing\Controller, Illuminate\Http\Request, Illuminate\Foundation\Http\FormRequest, App\Models\User, and the global auth() helper — illuminate/routing / illuminate/http are not vendor-installed on this package, mirroring the AuditSnapshotOnRetry _stubs.php pattern. 60 tests pass, 101 assertions. Coverage 86.00% (≥83 gate); MSI 82.47% (≥75 gate); Covered MSI 82.47% (≥75 gate). Two LogicalOr mutants on the Identifier-name guard clauses escape — equivalent-mutant precedent already accepted on LogRule lines 84/99. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AuthFacadeInController.php | 16 ++ .../AuthHelperInController.php | 15 ++ .../CurrentUserAttribute/AuthInAction.php | 17 ++ .../CurrentUserAttribute/AuthInJob.php | 18 +++ .../CurrentUserAttribute/AuthInMiddleware.php | 16 ++ .../RequestUserInController.php | 16 ++ .../RequestUserInControllerWithAssert.php | 24 +++ .../RequestUserInFormRequest.php | 27 ++++ .../CurrentUserAttribute/TopLevelAuthCall.php | 10 ++ .../UsesCurrentUserAttribute.php | 19 +++ .../Fixtures/CurrentUserAttribute/_stubs.php | 62 +++++++ .../EnforceCurrentUserAttributeRuleTest.php | 151 ++++++++++++++++++ 12 files changed, 391 insertions(+) create mode 100644 tests/Fixtures/CurrentUserAttribute/AuthFacadeInController.php create mode 100644 tests/Fixtures/CurrentUserAttribute/AuthHelperInController.php create mode 100644 tests/Fixtures/CurrentUserAttribute/AuthInAction.php create mode 100644 tests/Fixtures/CurrentUserAttribute/AuthInJob.php create mode 100644 tests/Fixtures/CurrentUserAttribute/AuthInMiddleware.php create mode 100644 tests/Fixtures/CurrentUserAttribute/RequestUserInController.php create mode 100644 tests/Fixtures/CurrentUserAttribute/RequestUserInControllerWithAssert.php create mode 100644 tests/Fixtures/CurrentUserAttribute/RequestUserInFormRequest.php create mode 100644 tests/Fixtures/CurrentUserAttribute/TopLevelAuthCall.php create mode 100644 tests/Fixtures/CurrentUserAttribute/UsesCurrentUserAttribute.php create mode 100644 tests/Fixtures/CurrentUserAttribute/_stubs.php create mode 100644 tests/Rules/EnforceCurrentUserAttributeRuleTest.php diff --git a/tests/Fixtures/CurrentUserAttribute/AuthFacadeInController.php b/tests/Fixtures/CurrentUserAttribute/AuthFacadeInController.php new file mode 100644 index 0000000..3f930af --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/AuthFacadeInController.php @@ -0,0 +1,16 @@ +user(); + } +} diff --git a/tests/Fixtures/CurrentUserAttribute/AuthInAction.php b/tests/Fixtures/CurrentUserAttribute/AuthInAction.php new file mode 100644 index 0000000..67b9308 --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/AuthInAction.php @@ -0,0 +1,17 @@ +user(); + } +} diff --git a/tests/Fixtures/CurrentUserAttribute/RequestUserInControllerWithAssert.php b/tests/Fixtures/CurrentUserAttribute/RequestUserInControllerWithAssert.php new file mode 100644 index 0000000..61a636a --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/RequestUserInControllerWithAssert.php @@ -0,0 +1,24 @@ +user() call fires. + $user = $request->user(); + assert($user instanceof User); + + return $user; + } +} diff --git a/tests/Fixtures/CurrentUserAttribute/RequestUserInFormRequest.php b/tests/Fixtures/CurrentUserAttribute/RequestUserInFormRequest.php new file mode 100644 index 0000000..521b2d1 --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/RequestUserInFormRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + /** + * @return array + */ + public function payload(Request $request): array + { + return ['user' => $request->user()]; + } +} diff --git a/tests/Fixtures/CurrentUserAttribute/TopLevelAuthCall.php b/tests/Fixtures/CurrentUserAttribute/TopLevelAuthCall.php new file mode 100644 index 0000000..1043877 --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/TopLevelAuthCall.php @@ -0,0 +1,10 @@ +getClassReflection()` returns +// null, so the rule must short-circuit at the no-class-reflection gate. +// Silent. Kills the FalseValue mutant on the null-reflection guard. +Auth::user(); diff --git a/tests/Fixtures/CurrentUserAttribute/UsesCurrentUserAttribute.php b/tests/Fixtures/CurrentUserAttribute/UsesCurrentUserAttribute.php new file mode 100644 index 0000000..a1e2b60 --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/UsesCurrentUserAttribute.php @@ -0,0 +1,19 @@ + + */ +final class EnforceCurrentUserAttributeRuleTest extends RuleTestCase +{ + private const string EXPECTED_REQUEST_USER = 'Authenticated-user resolution in controller methods uses the #[CurrentUser] container attribute. Add `#[\Illuminate\Container\Attributes\CurrentUser] User $user` to the method signature instead of calling $request->user() inside the body.'; + + private const string EXPECTED_AUTH_FACADE = 'Authenticated-user resolution in controller methods uses the #[CurrentUser] container attribute. Add `#[\Illuminate\Container\Attributes\CurrentUser] User $user` to the method signature instead of calling Auth::user() inside the body.'; + + private const string EXPECTED_AUTH_HELPER = 'Authenticated-user resolution in controller methods uses the #[CurrentUser] container attribute. Add `#[\Illuminate\Container\Attributes\CurrentUser] User $user` to the method signature instead of calling auth()->user() inside the body.'; + + public function testFlagsRequestUserInController(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/RequestUserInController.php', + ], + [ + [self::EXPECTED_REQUEST_USER, 14], + ], + ); + } + + public function testFlagsAuthFacadeInController(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/AuthFacadeInController.php', + ], + [ + [self::EXPECTED_AUTH_FACADE, 14], + ], + ); + } + + public function testFlagsAuthHelperInController(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/AuthHelperInController.php', + ], + [ + [self::EXPECTED_AUTH_HELPER, 13], + ], + ); + } + + public function testFlagsRequestUserInControllerWithAssertOnceOnTheUserCall(): void + { + // The exact PR #263 shape — `$user = $request->user(); assert($user instanceof User);` + // fires once on the `$request->user()` call. The downstream assert is noise the rule + // does not care about. + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/RequestUserInControllerWithAssert.php', + ], + [ + [self::EXPECTED_REQUEST_USER, 19], + ], + ); + } + + public function testIgnoresCurrentUserAttribute(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/UsesCurrentUserAttribute.php', + ], + [], + ); + } + + public function testIgnoresFormRequestUserResolution(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/RequestUserInFormRequest.php', + ], + [], + ); + } + + public function testIgnoresMiddlewareUserResolution(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/AuthInMiddleware.php', + ], + [], + ); + } + + public function testIgnoresActionUserResolution(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/AuthInAction.php', + ], + [], + ); + } + + public function testIgnoresJobUserResolution(): void + { + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/AuthInJob.php', + ], + [], + ); + } + + public function testIgnoresTopLevelCallOutsideAnyClass(): void + { + // Top-level call outside any class — `$scope->getClassReflection()` + // returns null, the no-class-reflection gate must short-circuit. + // Kills the FalseValue mutant on `insideControllerMethod()`'s + // null-reflection branch. + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/TopLevelAuthCall.php', + ], + [], + ); + } + + protected function getRule(): Rule + { + return new EnforceCurrentUserAttributeRule; + } +} From e122c00196ba442f419a65ac640ea0d925b97908 Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 22 May 2026 14:25:44 +0200 Subject: [PATCH 3/4] docs: update CHANGELOG / README / CLAUDE.md for EnforceCurrentUserAttributeRule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md [Unreleased] § Added — new entry capturing detection shape, scope gate, doctrine source, identifier, origin recon yield, versioning posture (candidate Major bump per ADR-0021 §Versioning), pre-cascade audit demand, multi-guard ergonomics note, and v1 out-of-scope items (Auth::guard('name')->user()). - README.md ## Rules table — new row in the canonical seven-rule table. - CLAUDE.md ## Rules shipped table — new row matching the rules-shipped format (rule | doctrine | identifier). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 ++ CLAUDE.md | 1 + README.md | 1 + 3 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b9774..fc5e884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added +- `EnforceCurrentUserAttributeRule` — flags calls to `Request::user()` / `Auth::user()` / `auth()->user()` inside classes extending `Illuminate\Routing\Controller`. The canonical fix is Laravel's `#[\Illuminate\Container\Attributes\CurrentUser]` container attribute on the method parameter — eliminates the implicit-nullable-then-assert dance (`$user = $request->user(); assert($user instanceof User);`) introduced by emmie PR #263 (EMMIE-0197) and recurring across the war-room fleet. Doctrine: war-room §Architectural Principles — Explicit over implicit. Identifier: `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser`. Detection branches on three call shapes via `CallLike` registration (mirrors `LogRule` v0.3.0 shape): `MethodCall` on `Illuminate\Http\Request` subtype receiver (type-based via `ObjectType::isSuperTypeOf()`); `MethodCall` whose receiver is a `FuncCall('auth')` (AST-shape match — the helper's return type is unloaded in stub-only environments); `StaticCall` resolving to `Illuminate\Support\Facades\Auth` (FQCN comparison via `$scope->resolveName()`). Scoped to Controller descendants via `ClassReflection::isSubclassOf(Illuminate\Routing\Controller)` — FormRequest descendants (where `$this->user()` is canonical because container-attribute injection does not apply to `FormRequest::rules()` / `toDto()` / `authorize()` invocations), middleware, services, Actions, jobs, and console commands are silent. Origin: war-room cross-territory recon 2026-05-22 (50+ violations across codebook, ublgenie, entreezuil, emmie; kendo already clean with 30 adopted sites). **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has un-migrated controllers). The release PR will determine whether this collapses into the existing v0.3.0 [Unreleased] block (already a Major) or cuts as a separate Major (v0.4.0) after v0.3.0 ships.** **Pre-cascade audit required before tagging** — consuming territories will need either Medic dispatches to migrate (ublgenie 6 sites, entreezuil 3 sites, emmie 2 sites) or PHPStan-baseline staging (codebook ~40+ sites — safer than mass-edit on lightly-staffed AVG/NEN-7510-downstream territory). Kendo gets a constraint bump only (zero violations). Multi-guard ergonomics (`#[CurrentUser] Client $client` resolves via client guard, `#[CurrentUser] User $user` via user guard — verified in emmie's `ClientController::me`) work as expected: Laravel dispatches by typed parameter. Out of scope v1: `Auth::guard('name')->user()` and other guard-specific resolution paths — rare, substitution is more nuanced (`#[CurrentUser(guard: 'name')]`), do not appear in the recon yield. + - `ForbidEloquentMutationInControllersRule` — bans Eloquent persistence APIs (`save`, `update`, `delete`, `create`, `destroy`, `forceDelete`, `forceFill`, `push`, `restore`, `touch`, and their `*OrFail` / `*Quietly` / `*OrCreate` variants — 24-method blocklist) on `Illuminate\Database\Eloquent\Model` subclasses and `Illuminate\Database\Eloquent\Builder` chains when the call site is inside an `App\Http\Controllers\*` class (including sub-namespaces like kendo's `App\Http\Controllers\Central\*`, matched via `str_starts_with`). Reads (`find`, `where`, `get`, `first`, `paginate`, `pluck`, `count`, `exists`, `query`) are deliberately permitted — route-model binding, ResourceData hydration, and policy checks need controller-level Model access; the doctrine line is "Controllers may READ Models, but MUST NOT mutate them." Identifier: `forbidEloquentMutationInControllers.eloquentMutationInController`. Doctrine: ADR-0011 (Action Class Architecture) — Actions are the chokepoint for mutations — combined with ADR-0019 (Explicit Model Hydration) — `Model::create()` / `fill()` / `forceFill()` / `update()` banned application-wide; this rule enforces the controller surface where the violations have been historically common. Algorithm: namespace gate (`App\Http\Controllers`) → recursively walk every `ClassMethod` body collecting `MethodCall` + `StaticCall` nodes → for `MethodCall`, fire if `ObjectType::isSuperTypeOf()` against `Model` OR `Builder` matches the receiver type and the method name is in the blocklist; for `StaticCall`, fire if `Scope::resolveName()` resolves to a Model subclass FQCN and the method name is in the blocklist. Builder coverage is type-aware (`User::query()->where(...)->update([...])` fires) — the generic parameter is not unwrapped because `ObjectType` matches `Builder` as a subtype of the unparameterized `Builder` cleanly, no brittle generic introspection needed. Supersedes the consumer-side string-match Pest arch tests in kendo (`backend/tests/Arch/ControllersTest.php` `controllers must not call Eloquent write methods directly`), ublgenie + entreezuil (`tests/Arch/ControllersTest.php` of the same shape), and the bridge subset in ISMS (`backend/tests/Architecture/ControllerCurrentUserTest.php` from PR #10, 2026-05-28). The string-match shape catches `->save(`, `->update([`, `->delete(`, `->forceDelete(` but cannot discriminate `Model::create()` from `Response::create()`, `Collection::push()` from `Model::push()`, or `->update($vars)` without an inline array literal — the type-aware AST inspection here closes those gaps. Cross-territory cascade post-merge: consumer Pest tests deleted; emmie + brick-inventory-orchestrator pick up coverage automatically on next composer update. Out of scope: non-`App\Http\Controllers\*` namespaces (Actions/Services/Jobs/Middleware are allowed to call persistence APIs), non-Eloquent receivers, dynamic method names (`$model->{$var}()` — value-flow analysis), variable class names in static calls (`$class::create(...)`). Closes war-room enforcement queue #87. **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has a controller calling Eloquent persistence APIs directly — the three territories currently running the string-match Pest test caught the bulk of these, but the type-aware shape will surface additional violations the string-match shape missed: `Model::create()`, `Model::destroy()`, chained Builder mutations, `*Quietly` variants, etc.). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory before tagging — three territories' Pest tests already closed the string-match-visible violators; the type-aware additional surface (Builder chains, `Model::create()`, `*Quietly` variants) may carry undetected violators.** - `EnforceAuditTransactionScopeRule` — enforces ADR-0029 (Audit Row Durability Contract) §Decision rule 3. Flags non-transactional state mutations (`StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` and their `Illuminate\Support\Facades\*` counterparts, mutation methods only) inside `transaction(...)` closures in `App\Actions\*` classes. Identifier: `enforceAuditTransactionScope.nonTransactionalMutationInClosure`. Doctrine: ADR-0029 §Decision rule 3. Seed: ISMS-0003 PR #7 commit `f1d357b` (2026-05-28) — three Auth Actions (`AuthenticateWorkerAction`, `VerifyTwoFactorChallengeAction`, `LogoutWorkerAction`) mutated `StatefulGuard` + `Session` state inside the transaction closure before the audit row write; an audit-write failure would have rolled back the audit row while leaving the session/guard mutation intact (A.8.15 / A.5.33 violation). Reads (`Auth::user()`, `Session::get()`, `Cache::get()`, etc.) are deliberately permitted — only mutations carry the rollback-vs-side-effect asymmetry. Instance-call detection matches the constructor-property's declared FQCN against the blocklist keys; static-call detection resolves the facade name via `Scope::resolveName()`. Nested `transaction(...)` calls inside an outer closure are walked transitively — a nested mutation is still inside the outermost transaction's rollback scope; top-level transaction discovery deduplicates so each violation reports exactly once. Out of scope: manual transaction management (`DB::beginTransaction()` / `commit()`); non-`App\Actions\*` namespaces; the failure-side discipline (sentinel-return; throw-inside-closure detection) which lives as per-territory Pest arch tests under enforcement queue #85. **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has an `App\Actions\*` class mutating non-transactional state inside a `transaction(...)` closure). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory before tagging — ISMS-0003 PR #7 commit `f1d357b` already closed ISMS's known violators; other consumer territories may carry undetected violators.** diff --git a/CLAUDE.md b/CLAUDE.md index d133930..4103457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev | `EnforceAuditTransactionScopeRule` | ADR-0029 | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` | | `ForbidEloquentMutationInControllersRule` | ADR-0011 + ADR-0019 | `forbidEloquentMutationInControllers.eloquentMutationInController` | | `EnforceResourceDataValidatorOptInRule` | ADR-0009 §EAGER_LOAD validator opt-in | `enforceResourceDataValidatorOptIn.missingValidatorCall` | +| `EnforceCurrentUserAttributeRule` | War-room §Explicit over implicit | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | | `ConnectionTransactionReturnTypeExtension` | (type extension, no rule) | — | Phase 2 expands the rule set: `EnforceAuditSnapshotOnRetryRule` (ADR-0001 §Snapshot-on-Retry Safety) was the first Phase 2 addition, promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). `EnforceResourceDataValidatorOptInRule` (ADR-0009 §EAGER_LOAD validator opt-in) is the second Phase 2 addition, promoted from kendo PR #1084 under war-room enforcement queue #55. `EnforceExplicitHydrationRule` (ADR-0019) is the next Phase 2 candidate. diff --git a/README.md b/README.md index ac86fb0..55ffc77 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ includes: | `EnforceAuditTransactionScopeRule` | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` | `App\Actions\*` whose `execute()` calls `transaction(...)` with a literal closure | Mutating `StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` state (or their `Illuminate\Support\Facades\*` counterparts) inside the closure is an error. Reads (`Auth::user()`, `Session::get()`, `Cache::get()`) are permitted. Doctrine: ADR-0029 (Audit Row Durability Contract) §Decision rule 3. | | `ForbidEloquentMutationInControllersRule` | `forbidEloquentMutationInControllers.eloquentMutationInController` | `App\Http\Controllers\*` (including sub-namespaces) | Calling Eloquent persistence APIs (`save`, `update`, `delete`, `create`, `destroy`, `forceDelete`, `forceFill`, `push`, `restore`, `touch`, and their `*OrFail` / `*Quietly` / `*OrCreate` variants — 24-method blocklist) on `Illuminate\Database\Eloquent\Model` subclasses or `Illuminate\Database\Eloquent\Builder` chains is an error. Reads (`find`, `where`, `get`, `first`, `paginate`, `pluck`, `count`, `exists`, `query`) are permitted. Delegate mutations to an Action. Doctrine: ADR-0011 (Action Class Architecture) + ADR-0019 (Explicit Model Hydration). | | `EnforceResourceDataValidatorOptInRule` | `enforceResourceDataValidatorOptIn.missingValidatorCall` | Classes extending `App\Http\Resources\ResourceData` | If the class declares a non-empty `EAGER_LOAD_COUNT` / `EAGER_LOAD_SUM` constant but never calls `validateRelationsLoaded()` in any method, error. | +| `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `Request::user()` / `Auth::user()` / `auth()->user()` calls inside Controller descendants | Use `#[\Illuminate\Container\Attributes\CurrentUser] User $user` on the method parameter. FormRequest descendants, middleware, services, Actions, jobs, and console commands are silent — container-attribute injection does not apply to FormRequest methods, and the other contexts have their own canonical resolution paths. | ### `EnforceActionTransactionsRule` — write-method list From 316b8a9f0f4ae62fdc7342cac3de17525d6edf24 Mon Sep 17 00:00:00 2001 From: Gerard Date: Mon, 1 Jun 2026 15:07:31 +0200 Subject: [PATCH 4/4] fix(EnforceCurrentUserAttributeRule): namespace gate replaces no-op ancestry gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller-detection gate used isSubclassOf(Illuminate\Routing\Controller), an ancestry check. 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 — a silent no-op caught by the agent-review sweep on PR #26 (review pullrequestreview-4401182606). Replace it with the App\Http\Controllers namespace prefix gate used by the sibling ForbidEloquentMutationInControllersRule ($scope->getNamespace() + str_starts_with). Matches the canonical "controllers are identified by the App\Http\Controllers namespace" convention. Regression proof: a base-less `final` controller fixture (RequestUserInBaselessController) that the rule MUST flag, plus a compliant #[CurrentUser] base-less controller (CurrentUserAttributeInBaselessController) that MUST pass. Before/after error count on the flagging fixture: namespace gate -> 1 error (line 18); revert to ancestry -> 0 errors (the bug). False-negative closure; no new identifier, no consumer ignoreErrors shape change. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++- src/Rules/EnforceCurrentUserAttributeRule.php | 49 +++++++++++-------- ...rrentUserAttributeInBaselessController.php | 20 ++++++++ .../RequestUserInBaselessController.php | 20 ++++++++ .../EnforceCurrentUserAttributeRuleTest.php | 32 ++++++++++++ 5 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 tests/Fixtures/CurrentUserAttribute/CurrentUserAttributeInBaselessController.php create mode 100644 tests/Fixtures/CurrentUserAttribute/RequestUserInBaselessController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5e884..947956a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Added -- `EnforceCurrentUserAttributeRule` — flags calls to `Request::user()` / `Auth::user()` / `auth()->user()` inside classes extending `Illuminate\Routing\Controller`. The canonical fix is Laravel's `#[\Illuminate\Container\Attributes\CurrentUser]` container attribute on the method parameter — eliminates the implicit-nullable-then-assert dance (`$user = $request->user(); assert($user instanceof User);`) introduced by emmie PR #263 (EMMIE-0197) and recurring across the war-room fleet. Doctrine: war-room §Architectural Principles — Explicit over implicit. Identifier: `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser`. Detection branches on three call shapes via `CallLike` registration (mirrors `LogRule` v0.3.0 shape): `MethodCall` on `Illuminate\Http\Request` subtype receiver (type-based via `ObjectType::isSuperTypeOf()`); `MethodCall` whose receiver is a `FuncCall('auth')` (AST-shape match — the helper's return type is unloaded in stub-only environments); `StaticCall` resolving to `Illuminate\Support\Facades\Auth` (FQCN comparison via `$scope->resolveName()`). Scoped to Controller descendants via `ClassReflection::isSubclassOf(Illuminate\Routing\Controller)` — FormRequest descendants (where `$this->user()` is canonical because container-attribute injection does not apply to `FormRequest::rules()` / `toDto()` / `authorize()` invocations), middleware, services, Actions, jobs, and console commands are silent. Origin: war-room cross-territory recon 2026-05-22 (50+ violations across codebook, ublgenie, entreezuil, emmie; kendo already clean with 30 adopted sites). **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has un-migrated controllers). The release PR will determine whether this collapses into the existing v0.3.0 [Unreleased] block (already a Major) or cuts as a separate Major (v0.4.0) after v0.3.0 ships.** **Pre-cascade audit required before tagging** — consuming territories will need either Medic dispatches to migrate (ublgenie 6 sites, entreezuil 3 sites, emmie 2 sites) or PHPStan-baseline staging (codebook ~40+ sites — safer than mass-edit on lightly-staffed AVG/NEN-7510-downstream territory). Kendo gets a constraint bump only (zero violations). Multi-guard ergonomics (`#[CurrentUser] Client $client` resolves via client guard, `#[CurrentUser] User $user` via user guard — verified in emmie's `ClientController::me`) work as expected: Laravel dispatches by typed parameter. Out of scope v1: `Auth::guard('name')->user()` and other guard-specific resolution paths — rare, substitution is more nuanced (`#[CurrentUser(guard: 'name')]`), do not appear in the recon yield. +- `EnforceCurrentUserAttributeRule` — flags calls to `Request::user()` / `Auth::user()` / `auth()->user()` inside classes extending `Illuminate\Routing\Controller`. The canonical fix is Laravel's `#[\Illuminate\Container\Attributes\CurrentUser]` container attribute on the method parameter — eliminates the implicit-nullable-then-assert dance (`$user = $request->user(); assert($user instanceof User);`) introduced by emmie PR #263 (EMMIE-0197) and recurring across the war-room fleet. Doctrine: war-room §Architectural Principles — Explicit over implicit. Identifier: `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser`. Detection branches on three call shapes via `CallLike` registration (mirrors `LogRule` v0.3.0 shape): `MethodCall` on `Illuminate\Http\Request` subtype receiver (type-based via `ObjectType::isSuperTypeOf()`); `MethodCall` whose receiver is a `FuncCall('auth')` (AST-shape match — the helper's return type is unloaded in stub-only environments); `StaticCall` resolving to `Illuminate\Support\Facades\Auth` (FQCN comparison via `$scope->resolveName()`). Scoped to controllers via the `App\Http\Controllers` namespace prefix (`$scope->getNamespace()` + `str_starts_with`) — mirrors `ForbidEloquentMutationInControllersRule` and the canonical "controllers are identified by the `App\Http\Controllers` namespace" convention. FormRequest (`App\Http\Requests`, where `$this->user()` is canonical because container-attribute injection does not apply to `FormRequest::rules()` / `toDto()` / `authorize()` invocations), middleware (`App\Http\Middleware`), services, Actions (`App\Actions`), jobs, and console commands are silent because their namespaces do not start with the controller prefix. Origin: war-room cross-territory recon 2026-05-22 (50+ violations across codebook, ublgenie, entreezuil, emmie; kendo already clean with 30 adopted sites). **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has un-migrated controllers). The release PR will determine whether this collapses into the existing v0.3.0 [Unreleased] block (already a Major) or cuts as a separate Major (v0.4.0) after v0.3.0 ships.** **Pre-cascade audit required before tagging** — consuming territories will need either Medic dispatches to migrate (ublgenie 6 sites, entreezuil 3 sites, emmie 2 sites) or PHPStan-baseline staging (codebook ~40+ sites — safer than mass-edit on lightly-staffed AVG/NEN-7510-downstream territory). Kendo gets a constraint bump only (zero violations). Multi-guard ergonomics (`#[CurrentUser] Client $client` resolves via client guard, `#[CurrentUser] User $user` via user guard — verified in emmie's `ClientController::me`) work as expected: Laravel dispatches by typed parameter. Out of scope v1: `Auth::guard('name')->user()` and other guard-specific resolution paths — rare, substitution is more nuanced (`#[CurrentUser(guard: 'name')]`), do not appear in the recon yield. - `ForbidEloquentMutationInControllersRule` — bans Eloquent persistence APIs (`save`, `update`, `delete`, `create`, `destroy`, `forceDelete`, `forceFill`, `push`, `restore`, `touch`, and their `*OrFail` / `*Quietly` / `*OrCreate` variants — 24-method blocklist) on `Illuminate\Database\Eloquent\Model` subclasses and `Illuminate\Database\Eloquent\Builder` chains when the call site is inside an `App\Http\Controllers\*` class (including sub-namespaces like kendo's `App\Http\Controllers\Central\*`, matched via `str_starts_with`). Reads (`find`, `where`, `get`, `first`, `paginate`, `pluck`, `count`, `exists`, `query`) are deliberately permitted — route-model binding, ResourceData hydration, and policy checks need controller-level Model access; the doctrine line is "Controllers may READ Models, but MUST NOT mutate them." Identifier: `forbidEloquentMutationInControllers.eloquentMutationInController`. Doctrine: ADR-0011 (Action Class Architecture) — Actions are the chokepoint for mutations — combined with ADR-0019 (Explicit Model Hydration) — `Model::create()` / `fill()` / `forceFill()` / `update()` banned application-wide; this rule enforces the controller surface where the violations have been historically common. Algorithm: namespace gate (`App\Http\Controllers`) → recursively walk every `ClassMethod` body collecting `MethodCall` + `StaticCall` nodes → for `MethodCall`, fire if `ObjectType::isSuperTypeOf()` against `Model` OR `Builder` matches the receiver type and the method name is in the blocklist; for `StaticCall`, fire if `Scope::resolveName()` resolves to a Model subclass FQCN and the method name is in the blocklist. Builder coverage is type-aware (`User::query()->where(...)->update([...])` fires) — the generic parameter is not unwrapped because `ObjectType` matches `Builder` as a subtype of the unparameterized `Builder` cleanly, no brittle generic introspection needed. Supersedes the consumer-side string-match Pest arch tests in kendo (`backend/tests/Arch/ControllersTest.php` `controllers must not call Eloquent write methods directly`), ublgenie + entreezuil (`tests/Arch/ControllersTest.php` of the same shape), and the bridge subset in ISMS (`backend/tests/Architecture/ControllerCurrentUserTest.php` from PR #10, 2026-05-28). The string-match shape catches `->save(`, `->update([`, `->delete(`, `->forceDelete(` but cannot discriminate `Model::create()` from `Response::create()`, `Collection::push()` from `Model::push()`, or `->update($vars)` without an inline array literal — the type-aware AST inspection here closes those gaps. Cross-territory cascade post-merge: consumer Pest tests deleted; emmie + brick-inventory-orchestrator pick up coverage automatically on next composer update. Out of scope: non-`App\Http\Controllers\*` namespaces (Actions/Services/Jobs/Middleware are allowed to call persistence APIs), non-Eloquent receivers, dynamic method names (`$model->{$var}()` — value-flow analysis), variable class names in static calls (`$class::create(...)`). Closes war-room enforcement queue #87. **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has a controller calling Eloquent persistence APIs directly — the three territories currently running the string-match Pest test caught the bulk of these, but the type-aware shape will surface additional violations the string-match shape missed: `Model::create()`, `Model::destroy()`, chained Builder mutations, `*Quietly` variants, etc.). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory before tagging — three territories' Pest tests already closed the string-match-visible violators; the type-aware additional surface (Builder chains, `Model::create()`, `*Quietly` variants) may carry undetected violators.** - `EnforceAuditTransactionScopeRule` — enforces ADR-0029 (Audit Row Durability Contract) §Decision rule 3. Flags non-transactional state mutations (`StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` and their `Illuminate\Support\Facades\*` counterparts, mutation methods only) inside `transaction(...)` closures in `App\Actions\*` classes. Identifier: `enforceAuditTransactionScope.nonTransactionalMutationInClosure`. Doctrine: ADR-0029 §Decision rule 3. Seed: ISMS-0003 PR #7 commit `f1d357b` (2026-05-28) — three Auth Actions (`AuthenticateWorkerAction`, `VerifyTwoFactorChallengeAction`, `LogoutWorkerAction`) mutated `StatefulGuard` + `Session` state inside the transaction closure before the audit row write; an audit-write failure would have rolled back the audit row while leaving the session/guard mutation intact (A.8.15 / A.5.33 violation). Reads (`Auth::user()`, `Session::get()`, `Cache::get()`, etc.) are deliberately permitted — only mutations carry the rollback-vs-side-effect asymmetry. Instance-call detection matches the constructor-property's declared FQCN against the blocklist keys; static-call detection resolves the facade name via `Scope::resolveName()`. Nested `transaction(...)` calls inside an outer closure are walked transitively — a nested mutation is still inside the outermost transaction's rollback scope; top-level transaction discovery deduplicates so each violation reports exactly once. Out of scope: manual transaction management (`DB::beginTransaction()` / `commit()`); non-`App\Actions\*` namespaces; the failure-side discipline (sentinel-return; throw-inside-closure detection) which lives as per-territory Pest arch tests under enforcement queue #85. **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has an `App\Actions\*` class mutating non-transactional state inside a `transaction(...)` closure). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory before tagging — ISMS-0003 PR #7 commit `f1d357b` already closed ISMS's known violators; other consumer territories may carry undetected violators.** +### Fixed + +- `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. + ### Changed - **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). diff --git a/src/Rules/EnforceCurrentUserAttributeRule.php b/src/Rules/EnforceCurrentUserAttributeRule.php index c9f0271..b26d567 100644 --- a/src/Rules/EnforceCurrentUserAttributeRule.php +++ b/src/Rules/EnforceCurrentUserAttributeRule.php @@ -14,25 +14,26 @@ use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PHPStan\Analyser\Scope; -use PHPStan\Reflection\ClassReflection; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\ObjectType; use function sprintf; +use function str_starts_with; /** * Flags Request::user() / Auth::user() / auth()->user() calls inside controller methods. * Use the #[\Illuminate\Container\Attributes\CurrentUser] container attribute on the * method parameter instead — eliminates the nullable-then-assert dance. * - * Scoped to classes extending Illuminate\Routing\Controller. FormRequest descendants - * are excluded by design — container-attribute injection does not apply to - * FormRequest::rules() / toDto() / authorize() invocations. Middleware, services, - * Actions, jobs, and console commands are likewise out of scope: each context has - * its own canonical resolution path (constructor DI for Actions, authenticated - * payload threading for jobs, etc.). + * Scoped to classes in the `App\Http\Controllers` namespace. FormRequest + * (`App\Http\Requests`) is excluded by design — container-attribute injection + * does not apply to FormRequest::rules() / toDto() / authorize() invocations. + * Middleware (`App\Http\Middleware`), services, Actions (`App\Actions`), jobs, + * and console commands are likewise out of scope: each context has its own + * canonical resolution path (constructor DI for Actions, authenticated payload + * threading for jobs, etc.). * * Detection (three call shapes branch in `processNode`): * 1. `MethodCall` — receiver type is a (subtype of) `Illuminate\Http\Request` @@ -45,9 +46,15 @@ * and method name is `user`. FQCN comparison via `$scope->resolveName()` * handles aliased imports. * - * Containing-class gate (applied to all three branches): walk - * `$scope->getClassReflection()` ancestry and fire only if the class extends - * `Illuminate\Routing\Controller`. Out-of-scope classes return silently. + * Containing-class gate (applied to all three branches): the rule fires only + * when `$scope->getNamespace()` starts with `App\Http\Controllers`. This + * mirrors `ForbidEloquentMutationInControllersRule` and the canonical + * "controllers are identified by the `App\Http\Controllers` namespace" + * convention — consumer controllers are base-less `final` classes with no + * `extends Controller`, so an ancestry walk catches nothing. Out-of-scope + * namespaces (FormRequest in `App\Http\Requests`, middleware in + * `App\Http\Middleware`, Actions in `App\Actions`, jobs, services) return + * silently because their namespaces do not start with the controller prefix. * * Implementation note: `getNodeType()` returns `CallLike::class` so the rule * sees both `MethodCall` and `StaticCall` in a single registration — mirrors @@ -61,7 +68,7 @@ final class EnforceCurrentUserAttributeRule implements Rule { private const string TARGET_METHOD = 'user'; - private const string CONTROLLER_BASE_FQCN = 'Illuminate\Routing\Controller'; + private const string CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'; private const string REQUEST_FQCN = Request::class; @@ -91,23 +98,23 @@ public function processNode(Node $node, Scope $scope): array /** * Containing-class gate: the rule fires only inside methods of a class - * that extends `Illuminate\Routing\Controller`. FormRequest descendants, - * middleware, services, Actions, jobs, and console commands are silent - * because their class hierarchies do not include Controller. + * whose namespace starts with `App\Http\Controllers`. FormRequest + * (`App\Http\Requests`), middleware (`App\Http\Middleware`), services, + * Actions (`App\Actions`), jobs, and console commands are silent because + * their namespaces do not start with the controller prefix. Mirrors + * `ForbidEloquentMutationInControllersRule` — consumer controllers are + * base-less `final` classes, so an ancestry walk to a base Controller + * catches nothing. */ private function insideControllerMethod(Scope $scope): bool { - $classReflection = $scope->getClassReflection(); + $namespace = $scope->getNamespace(); - if (!$classReflection instanceof ClassReflection) { + if ($namespace === null) { return false; } - if ($classReflection->getName() === self::CONTROLLER_BASE_FQCN) { - return false; - } - - return $classReflection->isSubclassOf(self::CONTROLLER_BASE_FQCN); + return str_starts_with($namespace, self::CONTROLLER_NAMESPACE_PREFIX); } /** diff --git a/tests/Fixtures/CurrentUserAttribute/CurrentUserAttributeInBaselessController.php b/tests/Fixtures/CurrentUserAttribute/CurrentUserAttributeInBaselessController.php new file mode 100644 index 0000000..8141669 --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/CurrentUserAttributeInBaselessController.php @@ -0,0 +1,20 @@ +user(); + } +} diff --git a/tests/Rules/EnforceCurrentUserAttributeRuleTest.php b/tests/Rules/EnforceCurrentUserAttributeRuleTest.php index 4047f27..48fdcfe 100644 --- a/tests/Rules/EnforceCurrentUserAttributeRuleTest.php +++ b/tests/Rules/EnforceCurrentUserAttributeRuleTest.php @@ -74,6 +74,38 @@ public function testFlagsRequestUserInControllerWithAssertOnceOnTheUserCall(): v ); } + public function testFlagsRequestUserInBaselessFinalController(): void + { + // Regression proof for the namespace-gate fix (PR #26 blocker). A + // base-less `final` controller with NO `extends Controller` — the exact + // shape kendo / ublgenie / entreezuil ship. The prior ancestry gate + // (`isSubclassOf(Controller)`) matched zero such classes, so the rule + // was a silent no-op. The namespace gate must flag this. + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/RequestUserInBaselessController.php', + ], + [ + [self::EXPECTED_REQUEST_USER, 18], + ], + ); + } + + public function testIgnoresCurrentUserAttributeInBaselessFinalController(): void + { + // Compliant base-less `final` controller — container-attribute injection, + // no body call. The rule must not fire even though the namespace gate + // now matches the class. + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/CurrentUserAttributeInBaselessController.php', + ], + [], + ); + } + public function testIgnoresCurrentUserAttribute(): void { $this->analyse(