From 13008ac4506a471d6bb2aa379f06bd7683ffd4d2 Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 22 May 2026 14:25:17 +0200 Subject: [PATCH 1/3] 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 a95ed89095556e519d0122d86d52c5daa0e6dffd Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 22 May 2026 14:25:33 +0200 Subject: [PATCH 2/3] 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 c2dff7f2541333f7566707d1255f2822f91d24d8 Mon Sep 17 00:00:00 2001 From: Gerard Date: Fri, 22 May 2026 14:25:44 +0200 Subject: [PATCH 3/3] 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 7c240e7..36fd814 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