diff --git a/CHANGELOG.md b/CHANGELOG.md index e72680c..a33518c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +**Release-as-a-whole: backward-compatible MINOR** — one new optional PHPStan parameter (`controllerNamespacePrefixes`, default `['App\Http\Controllers']`) shared by all three controller-scoped rules (`ForbidEloquentMutationInControllersRule`, `EnforceCurrentUserAttributeRule`, `ForbidResourceWrappedInJsonResponseRule`). The default reproduces the prior hardcoded `App\Http\Controllers` gate byte-for-byte ⇒ zero new errors in any existing consumer on adoption (NOT a candidate-major — contrast the v0.5.0 rules that surfaced errors in clean code); new violations appear only when a consumer opts a divergent controller namespace in by configuring the param. A consumer adopts on its own `^0.6 → ^0.7` pin bump. Seed: war-room WR-0283 / WR-0275 DayUpdate pilot — emmie ships controllers under `App\Http\Client\Controllers` (4) + `App\Http\Admin\Controllers` (9), outside the hardcoded prefix, so their inline Eloquent mutations, `$request->user()` calls, and resource-wrapped JSON responses were rule-invisible. + +### Added + +- `ForbidEloquentMutationInControllersRule` + `EnforceCurrentUserAttributeRule` + `ForbidResourceWrappedInJsonResponseRule` — new shared optional `controllerNamespacePrefixes` PHPStan parameter (default `['App\Http\Controllers']`): a list of namespace prefixes whose classes are treated as controllers. A class is in scope when its namespace `str_starts_with` **any** listed prefix, so sub-namespaces (kendo's `App\Http\Controllers\Central\*`) still match the canonical prefix naturally — the default is behaviour-identical to the prior hardcoded `private const CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'` + single-prefix `str_starts_with` gate all three rules carried before. Wired through `extension.neon` (`controllerNamespacePrefixes: ['App\Http\Controllers']` parameter + `listOf(string())` schema + `controllerNamespacePrefixes: %controllerNamespacePrefixes%` on all three rules' service registrations), mirroring the `formRequestBaseClass` / `resourceDataBaseClass` / `formRequestToDtoExemptClasses` parameter precedent. A consumer with divergent controller namespaces (emmie's `App\Http\Client\Controllers` + `App\Http\Admin\Controllers`) brings them into scope by adding the prefixes in its `phpstan.neon`. No consumer namespace is ever hardcoded in a rule body — the prefix list is *config*, preserving the package's "never by name inside the rule" convention. This resolves the `ForbidEloquentMutationInControllersRule` docblock's own standing TODO ("if a future consumer ships controllers under a divergent namespace, lift this into a `controllerNamespacePrefixes` parameter") — now done, and applied uniformly to all three controller-scoped rules so the one knob governs the whole controller surface. README documents the param + a "Covering sub-namespaced controllers" recipe using emmie's real namespaces. The default-unchanged invariant is pinned end-to-end by a container-resolved test per rule (`testRuleResolvesFromExtensionNeonAndFiresOnDefaultPrefix` — resolves the rule from the PHPStan container so the shipped NEON default + `%controllerNamespacePrefixes%` wiring are exercised, then asserts a canonical/sub-namespaced controller still flags) plus every pre-existing fixture and test (the kendo `App\Http\Controllers\Central\*` sub-namespace still flags under the default). A new sub-namespaced-controller fixture per rule proves the emmie shape is CLEAN under the default and FLAGGED once the sub-namespace prefix is configured. **Versioning: backward-compatible MINOR** (new optional parameter, default reproduces current behaviour ⇒ zero new errors in existing consumers — the default-list means no consumer sees behaviour change until it opts in). Do NOT push / tag from this entry — the release PR + tag is a separate ally-gated step. + ## [0.6.1] — 2026-07-01 **Release-as-a-whole: PATCH (false-positive narrowing — `^0.6` consumers inherit on a plain `composer update`, no pin bump).** Removes errors from previously-flagged code; adds none. Not a candidate-major. diff --git a/README.md b/README.md index b0a6cd7..95fcfc7 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ includes: | `LogBuilderTruncateRule` | `logRule.logModification` | `Builder->truncate()` calls | If the fluent chain's most recent `table()` call targets a Log-named table (string-literal argument matching `"log"` / `"logs"`, case-insensitive), error. Sibling rule to `LogRule`; shares the `logRule.logModification` identifier so a single `ignoreErrors` entry covers both. Eloquent `from()` chains and Model-`$table`-property-driven tables are acceptable misses. Doctrine: ADR-0001 §Append-only. | | `EnforceAuditSnapshotOnRetryRule` | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | `App\Actions\*` whose constructor injects an entity audit logger | The first statement inside `$connection->transaction(...)` must reset the model's in-memory state (`$model->refresh()`, fresh fetch, or fresh instantiation). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. | | `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). | +| `ForbidEloquentMutationInControllersRule` | `forbidEloquentMutationInControllers.eloquentMutationInController` | `App\Http\Controllers\*` (including sub-namespaces; configurable via `controllerNamespacePrefixes`) | 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. | | `EnforceFormRequestToDtoRule` | `enforceFormRequestToDto.missingToDtoMethod` | Concrete classes extending `Illuminate\Foundation\Http\FormRequest` | If the class neither declares nor inherits a `toDto()` method, error. Abstract intermediates (`BaseFormRequest`) are exempt. Hand Actions a typed DTO, not `$request->validated()` arrays. Doctrine: ADR-0012 (FormRequest → DTO Flow). | -| `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `Request::user()` / `Auth::user()` / `auth()->user()` calls inside `App\Http\Controllers\*` classes (namespace prefix, incl. sub-namespaces) | Use `#[\Illuminate\Container\Attributes\CurrentUser] User $user` on the method parameter. Scope is decided by namespace, not class ancestry — a base-less `final` controller in `App\Http\Controllers` fires; FormRequests (`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 (container-attribute injection does not apply to FormRequest methods regardless). | +| `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `Request::user()` / `Auth::user()` / `auth()->user()` calls inside `App\Http\Controllers\*` classes (namespace prefix, incl. sub-namespaces; configurable via `controllerNamespacePrefixes`) | Use `#[\Illuminate\Container\Attributes\CurrentUser] User $user` on the method parameter. Scope is decided by namespace, not class ancestry — a base-less `final` controller in `App\Http\Controllers` fires; FormRequests (`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 (container-attribute injection does not apply to FormRequest methods regardless). | ### `EnforceActionTransactionsRule` — write-method list @@ -153,6 +153,32 @@ parameters: Confirmed cross-territory (n=2, 2026-06-15): entreezuil `AuthenticatedSessionController::store`, ublgenie `AuthController::store`. Each consumer adds this on its `^0.4` bump. +### Configurable controller namespaces (`controllerNamespacePrefixes`) + +The three controller-scoped rules — `ForbidEloquentMutationInControllersRule`, `EnforceCurrentUserAttributeRule`, and `ForbidResourceWrappedInJsonResponseRule` — decide "is this class a controller?" by namespace prefix, not class ancestry (consumer controllers are base-less `final` classes with no `extends Controller`, so an ancestry walk catches nothing). The prefix set is the shared `controllerNamespacePrefixes` parameter, default `['App\Http\Controllers']`: + +```neon +parameters: + controllerNamespacePrefixes: + - 'App\Http\Controllers' +``` + +A class is in scope when its namespace `str_starts_with` **any** listed prefix, so canonical sub-namespaces (kendo's `App\Http\Controllers\Central\*`) are covered by the default automatically. The default reproduces the prior hardcoded gate byte-for-byte — leave it unset and nothing changes. + +#### Covering sub-namespaced controllers + +A territory that ships controllers **outside** `App\Http\Controllers` — e.g. emmie's `App\Http\Client\Controllers` and `App\Http\Admin\Controllers` — opts them into both rules by listing their prefixes: + +```neon +parameters: + controllerNamespacePrefixes: + - 'App\Http\Controllers' + - 'App\Http\Client\Controllers' + - 'App\Http\Admin\Controllers' +``` + +All three rules then flag inline Eloquent mutations, `Request::user()` / `Auth::user()` / `auth()->user()` calls, and `JsonResource`-wrapped JSON responses in those namespaces too. Prefixes are *config* — no consumer namespace is ever hardcoded in a rule body, preserving the "never by name inside the rule" convention. (Each backslash is single — NEON only unescapes `\\` inside double quotes; single-quoted `\\` stays two literal characters and would match nothing.) + ### Action namespace assumption `EnforceActionTransactionsRule` and `ForbidDatabaseManagerInActionsRule` only fire on classes whose namespace starts with `App\Actions`. This matches the Laravel convention used in every `script-development` territory. Territories using a different actions namespace should open a PR to make this configurable. diff --git a/extension.neon b/extension.neon index 6ca4179..037c187 100644 --- a/extension.neon +++ b/extension.neon @@ -23,10 +23,25 @@ parameters: # single backslashes — see the NEON-quoting note above. formRequestToDtoExemptClasses: [] + # `ForbidEloquentMutationInControllersRule` + `EnforceCurrentUserAttributeRule` + # + `ForbidResourceWrappedInJsonResponseRule`: namespace prefixes whose + # classes are treated as controllers. A class is + # in scope when its namespace `str_starts_with` ANY listed prefix, so + # sub-namespaces (kendo's `App\Http\Controllers\Central\*`) match the + # canonical prefix naturally. The default reproduces the prior hardcoded + # `App\Http\Controllers` gate byte-for-byte. A consumer with divergent + # controller namespaces (e.g. emmie's `App\Http\Client\Controllers` / + # `App\Http\Admin\Controllers`) adds those prefixes here to bring them into + # scope. Each prefix uses single backslashes — see the NEON-quoting note + # above. + controllerNamespacePrefixes: + - 'App\Http\Controllers' + parametersSchema: resourceDataBaseClass: string() formRequestBaseClass: string() formRequestToDtoExemptClasses: listOf(string()) + controllerNamespacePrefixes: listOf(string()) services: - @@ -43,6 +58,8 @@ services: tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Rules\ForbidResourceWrappedInJsonResponseRule + arguments: + controllerNamespacePrefixes: %controllerNamespacePrefixes% tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Rules\LogRule @@ -58,6 +75,8 @@ services: tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Rules\ForbidEloquentMutationInControllersRule + arguments: + controllerNamespacePrefixes: %controllerNamespacePrefixes% tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceResourceDataValidatorOptInRule @@ -72,6 +91,8 @@ services: tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceCurrentUserAttributeRule + arguments: + controllerNamespacePrefixes: %controllerNamespacePrefixes% tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Type\ConnectionTransactionReturnTypeExtension diff --git a/src/Rules/EnforceCurrentUserAttributeRule.php b/src/Rules/EnforceCurrentUserAttributeRule.php index 08e3196..c321702 100644 --- a/src/Rules/EnforceCurrentUserAttributeRule.php +++ b/src/Rules/EnforceCurrentUserAttributeRule.php @@ -27,9 +27,15 @@ * Use the #[\Illuminate\Container\Attributes\CurrentUser] container attribute on the * method parameter instead — eliminates the nullable-then-assert dance. * - * 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. + * Scoped to classes whose namespace starts with ANY of the configured + * `controllerNamespacePrefixes` (default `['App\Http\Controllers']`). A + * consumer that ships controllers under a divergent namespace (e.g. emmie's + * `App\Http\Client\Controllers` / `App\Http\Admin\Controllers`) opts them in + * by adding the prefix to `controllerNamespacePrefixes` in its `phpstan.neon` + * — mirrors the `formRequestBaseClass` / `resourceDataBaseClass` parameter + * precedent. 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 @@ -47,7 +53,8 @@ * handles aliased imports. * * Containing-class gate (applied to all three branches): the rule fires only - * when `$scope->getNamespace()` starts with `App\Http\Controllers`. This + * when `$scope->getNamespace()` starts with a configured controller prefix + * (default `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 @@ -68,12 +75,27 @@ final class EnforceCurrentUserAttributeRule implements Rule { private const string TARGET_METHOD = 'user'; - private const string CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'; - private const string REQUEST_FQCN = Request::class; private const string AUTH_FACADE_FQCN = Auth::class; + /** + * @param list $controllerNamespacePrefixes namespace prefixes whose + * classes are treated as + * controllers (match via + * `str_starts_with`); the + * default reproduces the + * canonical + * `App\Http\Controllers` + * gate. Consumers with + * sub-namespaced + * controllers add their + * prefixes from config. + */ + public function __construct( + private array $controllerNamespacePrefixes = ['App\Http\Controllers'], + ) {} + public function getNodeType(): string { return CallLike::class; @@ -114,7 +136,13 @@ private function insideControllerMethod(Scope $scope): bool return false; } - return str_starts_with($namespace, self::CONTROLLER_NAMESPACE_PREFIX); + foreach ($this->controllerNamespacePrefixes as $prefix) { + if (str_starts_with($namespace, $prefix)) { + return true; + } + } + + return false; } /** diff --git a/src/Rules/ForbidEloquentMutationInControllersRule.php b/src/Rules/ForbidEloquentMutationInControllersRule.php index 6512463..4020a66 100644 --- a/src/Rules/ForbidEloquentMutationInControllersRule.php +++ b/src/Rules/ForbidEloquentMutationInControllersRule.php @@ -57,11 +57,15 @@ * * Algorithm: * - * 1. Namespace gate — class FQCN must start with `App\Http\Controllers`. - * Sub-namespaces (kendo's `App\Http\Controllers\Central\*`) pass naturally - * via `str_starts_with`. If a future consumer ships controllers under a - * divergent namespace, lift this into a `controllerNamespacePrefixes` - * parameter per the `EnforceResourceDataValidatorOptInRule` precedent. + * 1. Namespace gate — the class namespace must start with ANY of the + * configured `controllerNamespacePrefixes` (default + * `['App\Http\Controllers']`). Sub-namespaces (kendo's + * `App\Http\Controllers\Central\*`) pass naturally via `str_starts_with`. + * A consumer that ships controllers under a divergent namespace (e.g. + * emmie's `App\Http\Client\Controllers` / `App\Http\Admin\Controllers`) + * opts them in by adding the prefix to `controllerNamespacePrefixes` in + * its `phpstan.neon` — mirrors the `formRequestBaseClass` / + * `resourceDataBaseClass` parameter precedent. * 2. For every `ClassMethod` in the class node, recursively walk the method * body collecting `MethodCall` and `StaticCall` nodes. * 3. **MethodCall:** resolve the receiver expression's type via @@ -109,8 +113,6 @@ */ final class ForbidEloquentMutationInControllersRule implements Rule { - private const string CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'; - /** * Eloquent persistence-API method names that mutate model or table state. * Reads are deliberately omitted — controllers reading Models is necessary @@ -130,6 +132,23 @@ final class ForbidEloquentMutationInControllersRule implements Rule 'touch', 'touchQuietly', ]; + /** + * @param list $controllerNamespacePrefixes namespace prefixes whose + * classes are treated as + * controllers (match via + * `str_starts_with`); the + * default reproduces the + * canonical + * `App\Http\Controllers` + * gate. Consumers with + * sub-namespaced + * controllers add their + * prefixes from config. + */ + public function __construct( + private array $controllerNamespacePrefixes = ['App\Http\Controllers'], + ) {} + public function getNodeType(): string { return Class_::class; @@ -139,7 +158,7 @@ public function processNode(Node $node, Scope $scope): array { $namespace = $scope->getNamespace(); - if ($namespace === null || !str_starts_with($namespace, self::CONTROLLER_NAMESPACE_PREFIX)) { + if ($namespace === null || !$this->namespaceIsController($namespace)) { return []; } @@ -158,6 +177,22 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + /** + * True when `$namespace` starts with ANY configured controller prefix. + * Sub-namespaces (`App\Http\Controllers\Central`) match the canonical + * `App\Http\Controllers` prefix naturally. + */ + private function namespaceIsController(string $namespace): bool + { + foreach ($this->controllerNamespacePrefixes as $prefix) { + if (str_starts_with($namespace, $prefix)) { + return true; + } + } + + return false; + } + /** * @return list */ diff --git a/src/Rules/ForbidResourceWrappedInJsonResponseRule.php b/src/Rules/ForbidResourceWrappedInJsonResponseRule.php index 74942f4..bb9c79c 100644 --- a/src/Rules/ForbidResourceWrappedInJsonResponseRule.php +++ b/src/Rules/ForbidResourceWrappedInJsonResponseRule.php @@ -76,19 +76,39 @@ * identifier `forbidResourceWrappedInJsonResponse.resourceWrapped`. * * Controller-namespace gate mirrors `ForbidEloquentMutationInControllersRule` / - * `EnforceCurrentUserAttributeRule`: `App\Http\Controllers` prefix via - * `$scope->getNamespace()` + `str_starts_with` (sub-namespaces pass naturally). + * `EnforceCurrentUserAttributeRule`: the class namespace must start with ANY of + * the configured `controllerNamespacePrefixes` (default + * `['App\Http\Controllers']`, reproducing the prior hardcoded gate byte-for- + * byte; sub-namespaces pass naturally via `str_starts_with`). A consumer that + * ships controllers under a divergent namespace (e.g. emmie's + * `App\Http\Client\Controllers` / `App\Http\Admin\Controllers`) opts them in by + * adding the prefix to `controllerNamespacePrefixes` in its `phpstan.neon`. * * @implements Rule */ final class ForbidResourceWrappedInJsonResponseRule implements Rule { - private const string CONTROLLER_NAMESPACE_PREFIX = 'App\Http\Controllers'; - private const string JSON_RESOURCE_CLASS = JsonResource::class; private const string JSON_RESPONSE_CLASS = JsonResponse::class; + /** + * @param list $controllerNamespacePrefixes namespace prefixes whose + * classes are treated as + * controllers (match via + * `str_starts_with`); the + * default reproduces the + * canonical + * `App\Http\Controllers` + * gate. Consumers with + * sub-namespaced + * controllers add their + * prefixes from config. + */ + public function __construct( + private array $controllerNamespacePrefixes = ['App\Http\Controllers'], + ) {} + public function getNodeType(): string { return Node::class; @@ -98,7 +118,7 @@ public function processNode(Node $node, Scope $scope): array { $namespace = $scope->getNamespace(); - if ($namespace === null || !str_starts_with($namespace, self::CONTROLLER_NAMESPACE_PREFIX)) { + if ($namespace === null || !$this->namespaceIsController($namespace)) { return []; } @@ -117,6 +137,22 @@ public function processNode(Node $node, Scope $scope): array return [$this->buildError($node)]; } + /** + * True when `$namespace` starts with ANY configured controller prefix. + * Sub-namespaces (`App\Http\Controllers\Central`) match the canonical + * `App\Http\Controllers` prefix naturally. + */ + private function namespaceIsController(string $namespace): bool + { + foreach ($this->controllerNamespacePrefixes as $prefix) { + if (str_starts_with($namespace, $prefix)) { + return true; + } + } + + return false; + } + /** * Returns the first (payload) argument of a `response()->json(...)` call or * a `new JsonResponse(...)` expression, or null if the node is neither. diff --git a/tests/Fixtures/CurrentUserAttribute/SubNamespacedClientController.php b/tests/Fixtures/CurrentUserAttribute/SubNamespacedClientController.php new file mode 100644 index 0000000..d6cf406 --- /dev/null +++ b/tests/Fixtures/CurrentUserAttribute/SubNamespacedClientController.php @@ -0,0 +1,21 @@ +user()` is INVISIBLE under the default config (CLEAN) +// and is only FLAGGED once a consumer opts the prefix in via +// `controllerNamespacePrefixes`. Proves the namespace gate is configurable +// while the default stays byte-for-byte the prior behaviour. +final class SubNamespacedClientController +{ + public function store(Request $request): ?object + { + return $request->user(); + } +} diff --git a/tests/Fixtures/ForbidEloquentMutationInControllers/SubNamespacedClientController.php b/tests/Fixtures/ForbidEloquentMutationInControllers/SubNamespacedClientController.php new file mode 100644 index 0000000..722b3cf --- /dev/null +++ b/tests/Fixtures/ForbidEloquentMutationInControllers/SubNamespacedClientController.php @@ -0,0 +1,26 @@ +name = 'Updated'; + $user->save(); + + return $user; + } +} diff --git a/tests/Fixtures/ResourceWrappedInJsonResponse/SubNamespacedClientController.php b/tests/Fixtures/ResourceWrappedInJsonResponse/SubNamespacedClientController.php new file mode 100644 index 0000000..82391a2 --- /dev/null +++ b/tests/Fixtures/ResourceWrappedInJsonResponse/SubNamespacedClientController.php @@ -0,0 +1,22 @@ +json(EmailResource::fromModel($email), 201); + } +} diff --git a/tests/Rules/EnforceCurrentUserAttributeRuleTest.php b/tests/Rules/EnforceCurrentUserAttributeRuleTest.php index 7064144..42e0a3a 100644 --- a/tests/Rules/EnforceCurrentUserAttributeRuleTest.php +++ b/tests/Rules/EnforceCurrentUserAttributeRuleTest.php @@ -19,6 +19,13 @@ final class EnforceCurrentUserAttributeRuleTest extends RuleTestCase 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.'; + /** + * Override hook: when set, `getRule()` returns this instance instead of + * the default. Lets a single test reconfigure the + * `controllerNamespacePrefixes` parameter. + */ + private ?Rule $ruleOverride = null; + public function testFlagsRequestUserInController(): void { $this->analyse( @@ -176,8 +183,82 @@ public function testIgnoresTopLevelCallOutsideAnyClass(): void ); } + public function testSubNamespacedControllerIsCleanUnderDefaultConfig(): void + { + // emmie's `App\Http\Client\Controllers` namespace does NOT start with + // the default `App\Http\Controllers` prefix, so `$request->user()` is + // invisible to the default gate — no error. This pins the "zero + // behaviour change at the default" invariant: the sub-namespace stays + // out of scope unless a consumer opts it in. + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/SubNamespacedClientController.php', + ], + [], + ); + } + + public function testSubNamespacedControllerFlaggedWhenPrefixConfigured(): void + { + // Re-run the same fixture with the sub-namespace added to + // `controllerNamespacePrefixes` — `$request->user()` must now fire. + // Proves the parameter brings a divergent controller namespace into + // scope (the emmie opt-in path). + $this->ruleOverride = new EnforceCurrentUserAttributeRule( + ['App\Http\Controllers', 'App\Http\Client\Controllers'], + ); + + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/SubNamespacedClientController.php', + ], + [ + [self::EXPECTED_REQUEST_USER, 19], + ], + ); + } + + public function testRuleResolvesFromExtensionNeonAndFiresOnDefaultPrefix(): void + { + // End-to-end pin on the extension.neon registration path consumers + // actually use: resolve the rule from the PHPStan container so the + // shipped `controllerNamespacePrefixes` default and the + // `%controllerNamespacePrefixes%` argument wiring are exercised — NOT + // the PHP constructor default. A NEON quoting regression in the shipped + // default would silently no-op the rule for every default consumer; + // this gate catches it by asserting a canonical `App\Http\Controllers` + // controller still flags under the shipped default. + $this->ruleOverride = self::getContainer()->getByType(EnforceCurrentUserAttributeRule::class); + + $this->analyse( + [ + __DIR__ . '/../Fixtures/CurrentUserAttribute/_stubs.php', + __DIR__ . '/../Fixtures/CurrentUserAttribute/RequestUserInController.php', + ], + [ + [self::EXPECTED_REQUEST_USER, 14], + ], + ); + } + + /** + * Load the shipped extension.neon so testRuleResolvesFromExtensionNeonAndFires* + * can pull the rule out of the container with its NEON-configured + * `controllerNamespacePrefixes` parameter applied. + * + * @return array + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../extension.neon', + ]; + } + protected function getRule(): Rule { - return new EnforceCurrentUserAttributeRule; + return $this->ruleOverride ?? new EnforceCurrentUserAttributeRule; } } diff --git a/tests/Rules/ForbidEloquentMutationInControllersRuleTest.php b/tests/Rules/ForbidEloquentMutationInControllersRuleTest.php index 4b3bad9..beaf338 100644 --- a/tests/Rules/ForbidEloquentMutationInControllersRuleTest.php +++ b/tests/Rules/ForbidEloquentMutationInControllersRuleTest.php @@ -15,6 +15,13 @@ */ final class ForbidEloquentMutationInControllersRuleTest extends RuleTestCase { + /** + * Override hook: when set, `getRule()` returns this instance instead of + * the default. Lets a single test reconfigure the + * `controllerNamespacePrefixes` parameter. + */ + private ?Rule $ruleOverride = null; + public function testCompliantReadOnlyController(): void { $this->analyse( @@ -185,9 +192,87 @@ public function testViolationBuilderUpdate(): void ); } + public function testSubNamespacedControllerIsCleanUnderDefaultConfig(): void + { + // emmie's `App\Http\Client\Controllers` namespace does NOT start with + // the default `App\Http\Controllers` prefix, so the Eloquent mutation + // is invisible to the default gate — no error. This pins the + // "zero behaviour change at the default" invariant: the sub-namespace + // stays out of scope unless a consumer opts it in. + $this->analyse( + [ + __DIR__ . '/../Fixtures/ForbidEloquentMutationInControllers/_stubs.php', + __DIR__ . '/../Fixtures/ForbidEloquentMutationInControllers/SubNamespacedClientController.php', + ], + [], + ); + } + + public function testSubNamespacedControllerFlaggedWhenPrefixConfigured(): void + { + // Re-run the same fixture with the sub-namespace added to + // `controllerNamespacePrefixes` — the mutation must now fire. Proves + // the parameter brings a divergent controller namespace into scope + // (the emmie opt-in path). + $this->ruleOverride = new ForbidEloquentMutationInControllersRule( + ['App\Http\Controllers', 'App\Http\Client\Controllers'], + ); + + $this->analyse( + [ + __DIR__ . '/../Fixtures/ForbidEloquentMutationInControllers/_stubs.php', + __DIR__ . '/../Fixtures/ForbidEloquentMutationInControllers/SubNamespacedClientController.php', + ], + [ + [ + $this->message('App\Http\Client\Controllers\SubNamespacedClientController', 'save', 'User'), + 22, + ], + ], + ); + } + + public function testRuleResolvesFromExtensionNeonAndFiresOnDefaultPrefix(): void + { + // End-to-end pin on the extension.neon registration path consumers + // actually use: resolve the rule from the PHPStan container so the + // shipped `controllerNamespacePrefixes` default and the + // `%controllerNamespacePrefixes%` argument wiring are exercised — NOT + // the PHP constructor default. A NEON quoting regression in the shipped + // default (e.g. the double-backslash single-quoted form) would silently + // no-op the rule for every default consumer; this gate catches it by + // asserting the kendo `App\Http\Controllers\Central\*` sub-namespace + // still flags under the shipped default. + $this->ruleOverride = self::getContainer()->getByType(ForbidEloquentMutationInControllersRule::class); + + $this->analyse( + [__DIR__ . '/../Fixtures/ForbidEloquentMutationInControllers/ViolationKendoCentralSubnamespace.php'], + [ + [ + $this->message('App\Http\Controllers\Central\IssueController', 'save', 'Post'), + 21, + ], + ], + ); + } + + /** + * Load the shipped extension.neon so testRuleResolvesFromExtensionNeonAndFires* + * can pull the rule out of the container with its NEON-configured + * `controllerNamespacePrefixes` parameter applied. + * + * @return array + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../extension.neon', + ]; + } + protected function getRule(): Rule { - return new ForbidEloquentMutationInControllersRule; + return $this->ruleOverride ?? new ForbidEloquentMutationInControllersRule; } private function message(string $classFqcn, string $method, string $receiverShortName): string diff --git a/tests/Rules/ForbidResourceWrappedInJsonResponseRuleTest.php b/tests/Rules/ForbidResourceWrappedInJsonResponseRuleTest.php index eb8ba40..e32fe2f 100644 --- a/tests/Rules/ForbidResourceWrappedInJsonResponseRuleTest.php +++ b/tests/Rules/ForbidResourceWrappedInJsonResponseRuleTest.php @@ -16,6 +16,13 @@ final class ForbidResourceWrappedInJsonResponseRuleTest extends RuleTestCase private const string MESSAGE = 'Controllers must not wrap a JsonResource in response()->json(...) / new JsonResponse(...) — a resource is already a Responsable. ' . 'Return the resource directly: return XxxResource::fromModel($model);'; + /** + * Override hook: when set, `getRule()` returns this instance instead of + * the default. Lets a single test reconfigure the + * `controllerNamespacePrefixes` parameter. + */ + private ?Rule $ruleOverride = null; + public function testFlagsResourceInResponseJson(): void { $this->analyse( @@ -68,8 +75,73 @@ public function testIgnoresResourceWrappedOutsideControllers(): void ); } + public function testSubNamespacedControllerIsCleanUnderDefaultConfig(): void + { + // emmie's `App\Http\Client\Controllers` namespace does NOT start with + // the default `App\Http\Controllers` prefix, so the resource-wrap is + // invisible to the default gate — no error. This pins the "zero + // behaviour change at the default" invariant: the sub-namespace stays + // out of scope unless a consumer opts it in. + $this->analyse( + [__DIR__ . '/../Fixtures/ResourceWrappedInJsonResponse/SubNamespacedClientController.php'], + [], + ); + } + + public function testSubNamespacedControllerFlaggedWhenPrefixConfigured(): void + { + // Re-run the same fixture with the sub-namespace added to + // `controllerNamespacePrefixes` — the resource-wrap must now fire. + // Proves the parameter brings a divergent controller namespace into + // scope (the emmie opt-in path). + $this->ruleOverride = new ForbidResourceWrappedInJsonResponseRule( + ['App\Http\Controllers', 'App\Http\Client\Controllers'], + ); + + $this->analyse( + [__DIR__ . '/../Fixtures/ResourceWrappedInJsonResponse/SubNamespacedClientController.php'], + [ + [self::MESSAGE, 20], + ], + ); + } + + public function testRuleResolvesFromExtensionNeonAndFiresOnDefaultPrefix(): void + { + // End-to-end pin on the extension.neon registration path consumers + // actually use: resolve the rule from the PHPStan container so the + // shipped `controllerNamespacePrefixes` default and the + // `%controllerNamespacePrefixes%` argument wiring are exercised — NOT + // the PHP constructor default. A NEON quoting regression in the shipped + // default would silently no-op the rule for every default consumer; + // this gate catches it by asserting a canonical `App\Http\Controllers` + // resource-wrap still flags under the shipped default. + $this->ruleOverride = self::getContainer()->getByType(ForbidResourceWrappedInJsonResponseRule::class); + + $this->analyse( + [__DIR__ . '/../Fixtures/ResourceWrappedInJsonResponse/WrapsResourceInResponseJson.php'], + [ + [self::MESSAGE, 14], + ], + ); + } + + /** + * Load the shipped extension.neon so testRuleResolvesFromExtensionNeonAndFires* + * can pull the rule out of the container with its NEON-configured + * `controllerNamespacePrefixes` parameter applied. + * + * @return array + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../extension.neon', + ]; + } + protected function getRule(): Rule { - return new ForbidResourceWrappedInJsonResponseRule; + return $this->ruleOverride ?? new ForbidResourceWrappedInJsonResponseRule; } }