Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +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-majorcontrast 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.
**Release-as-a-whole: candidate MAJOR** — two entries. The shared optional `controllerNamespacePrefixes` parameter is a backward-compatible MINOR on its own (default reproduces the prior hardcoded gate byte-for-byte — see its bullet); the new `EnforceAuditModelProtectionsRule` is the candidate-MAJOR driver (it surfaces new errors in any consumer with an unprotected audit modelsee its bullet), so the release as a whole classifies as candidate MAJOR. Per the pre-1.0 caret convention `^0.6` excludes the next minor, so tagging auto-adopts nobody — each consumer adopts on its own pin-bump PR. Seeds: war-room WR-0283 / WR-0275 DayUpdate pilot (controller prefixes); kendo Quartermaster M13 F-1 + war-room enforcement queue #46 (audit rule).

### 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.
- `EnforceAuditModelProtectionsRule` — new rule enforcing ADR-0001 §Append-only on audit-log models, discovered by SHAPE rather than a hand-maintained class list (a **denylist inversion**, war-room enforcement queue #46). An Eloquent `Model` subclass is treated as an audit record when its short name ends with any configured suffix (`auditModelNameSuffixes`, default `['AuditLog']`) **OR** its FQCN sits under any configured namespace prefix (`auditModelNamespacePrefixes`, default `['App\Models\Audit']`) — a union covering both fleet identification strategies: kendo's `*AuditLog` models scattered across `App\Models` + `App\Models\Central` (suffix), and the entreezuil / ublgenie `App\Models\Audit\*` collection including non-`AuditLog`-suffixed channel logs like `AuthEventLog` / `SmsEventLog` (namespace). The rule then flags three append-only protections, each firing independently at the class line: `HasFactory` present (`enforceAuditModelProtections.hasFactoryForbidden` — a factory is a direct-insert path bypassing the hash-chained writer), `SoftDeletes` present (`.softDeletesForbidden` — audit rows are never removed), and a mutable `updated_at` (`.updatedAtNotDisabled` — the model does not declare `public const UPDATED_AT = null`). Trait detection is transitive (inherited / composed traits count); abstract intermediates are exempt (their concrete leaves carry inherited violations); non-model classes named `*AuditLog` are excluded by the Eloquent `Model` type gate. **This is the inverse of the allowlist arch tests it supersedes** (kendo `tests/Arch/AuditTest.php`'s 13-FQCN `HasFactory` / `SoftDeletes` lists; entreezuil `tests/Architecture/AuditTest.php` + ublgenie `tests/Arch/AuditTest.php` namespace sweeps + `UPDATED_AT` reflection checks) — a hand-maintained list silently exempts every future audit model added outside it, the exact omission-escape the inversion closes; a territory retires its local model-side checks by moving the discovery convention into the two parameters. Configuration expresses patterns, never enumerated class names — no consumer class name is hardcoded in the rule body. Wired through `extension.neon` (`auditModelNamespacePrefixes` / `auditModelNameSuffixes` parameters + `listOf(string())` schemas + `%...%` arguments on the service registration). README documents discovery, the three protections, and the migration recipe. A model that disables timestamps wholesale (`public $timestamps = false;`) is recognised natively as satisfying the updated_at protection — no `ignoreErrors` suppression needed; the rule reads the native `$timestamps` default alongside the `UPDATED_AT` constant, matching Eloquent's own decision points — pinned by the `TimestamplessAuditLog` fixture. The container-resolved NEON test exercises the two shipped discovery defaults in ISOLATION (`ScatteredAuditLog` = suffix-only, `AuthEventLog` = namespace-only), so a quoting regression in either `extension.neon` parameter fails on its own instead of hiding behind the other signal. **Versioning: candidate MAJOR bump** (the rule surfaces new errors in any consumer territory that has an audit model using `HasFactory` / `SoftDeletes` or missing `const UPDATED_AT = null`). Per the pre-1.0 caret convention `^0.6` excludes the next minor, so tagging auto-adopts nobody — each consumer remediates and goes green on its own bump PR (a suppress-only / baseline-absorb posture: real gaps parked, rule bugs fixed upstream). Seed: kendo Quartermaster M13 F-1 (2026-04-22), war-room enforcement queue #46. **NOT tagged** (release is ally-gated).

## [0.6.1] — 2026-07-01

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev
| `EnforceResourceDataValidatorOptInRule` | ADR-0009 §EAGER_LOAD validator opt-in | `enforceResourceDataValidatorOptIn.missingValidatorCall` |
| `EnforceFormRequestToDtoRule` | ADR-0012 §FormRequest → DTO Flow | `enforceFormRequestToDto.missingToDtoMethod` |
| `EnforceCurrentUserAttributeRule` | War-room §Explicit over implicit | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` |
| `EnforceAuditModelProtectionsRule` | ADR-0001 §Append-only | `enforceAuditModelProtections.hasFactoryForbidden` / `.softDeletesForbidden` / `.updatedAtNotDisabled` (denylist-inversion; discovers audit models by shape — `auditModelNameSuffixes` default `AuditLog` OR `auditModelNamespacePrefixes` default `App\Models\Audit` — and flags `HasFactory` / `SoftDeletes` / missing `const UPDATED_AT = null`. `[Unreleased]`) |
| `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. `EnforceFormRequestToDtoRule` (ADR-0012) is the third Phase 2 addition, promoted from entreezuil's `tests/Arch/FormRequestsTest.php` under the same queue #55 (instance 2). `EnforceExplicitHydrationRule` (ADR-0019) is the next Phase 2 candidate.
Expand Down Expand Up @@ -93,7 +94,7 @@ SemVer per ADR-0021:

> Each bullet's rule→doctrine mapping is authoritative per the rule class's docblock "Doctrine source" line (ADR-0021 §Doctrine source in docblock).

- ADR-0001 (Audit Logging) — package distributes `LogRule` + `LogBuilderTruncateRule` (both §Append-only) + `EnforceAuditSnapshotOnRetryRule` (§Snapshot-on-Retry Safety); does not itself maintain audit logs.
- ADR-0001 (Audit Logging) — package distributes `LogRule` + `LogBuilderTruncateRule` (both §Append-only), `EnforceAuditSnapshotOnRetryRule` (§Snapshot-on-Retry Safety), and `EnforceAuditModelProtectionsRule` (§Append-only — flags audit-log models, discovered by shape, that use `HasFactory` / `SoftDeletes` or fail to disable `updated_at`; a denylist inversion of the consumer-side audit-model arch tests, `[Unreleased]`); does not itself maintain audit logs.
- ADR-0002 (Cascade Deletion) — no application surface.
- ADR-0009 (Unified ResourceData Pattern) — package distributes `EnforceResourceDataValidatorOptInRule` (§EAGER_LOAD validator opt-in, shipped in v0.3.0) and `ForbidResourceWrappedInJsonResponseRule` (resources own their own response serialization — bans wrapping a `JsonResource` in `response()->json()` / `new JsonResponse()` inside controllers, `[Unreleased]`); does not itself ship API resources.
- ADR-0011 (Action Class Architecture) — package distributes `EnforceActionTransactionsRule` + `ForbidDatabaseManagerInActionsRule`, and `ForbidEloquentMutationInControllersRule` (ADR-0011 + ADR-0019, `[Unreleased]`); itself has no Actions.
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ includes:
| `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; 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). |
| `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). |
Comment thread
Goosterhof marked this conversation as resolved.
| `EnforceAuditModelProtectionsRule` | `enforceAuditModelProtections.hasFactoryForbidden` / `.softDeletesForbidden` / `.updatedAtNotDisabled` | Eloquent models recognised as audit records by SHAPE — short name ends with a configured suffix (default `AuditLog`) OR FQCN sits under a configured namespace (default `App\Models\Audit`) | Three append-only protections, each firing independently: using `HasFactory` (a factory is a direct-insert path bypassing the hash-chained writer), using `SoftDeletes` (audit rows are never removed), or not disabling `updated_at` (an audit row is written once and never mutated — declare `public const UPDATED_AT = null;`) is an error. Discovery is by pattern, never a hand-maintained class list — a denylist inversion, so a newly-added audit model cannot escape the protections by omission. Abstract intermediates are exempt (their concrete leaves carry inherited violations). Non-model classes named `*AuditLog` are excluded by the Eloquent `Model` type gate. Doctrine: ADR-0001 §Append-only. |

### `EnforceActionTransactionsRule` — write-method list

Expand Down Expand Up @@ -178,6 +180,52 @@ parameters:
```

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.)
### `EnforceAuditModelProtectionsRule` — configurable discovery (denylist inversion)

This rule is the **inverse** of an allowlist arch test. The Pest predecessors it supersedes (kendo `tests/Arch/AuditTest.php`, entreezuil `tests/Architecture/AuditTest.php`, ublgenie `tests/Arch/AuditTest.php`) enumerate audit models — by a hand-maintained FQCN list or a namespace directory sweep — and assert each lacks `HasFactory` / `SoftDeletes` / a mutable `updated_at`. A hand-maintained list silently exempts every future audit model added outside it. This rule scans for the audit-model *shape* and flags any that lacks a protection, so nothing escapes by being forgotten.

**Discovery** — an Eloquent `Model` subclass is an audit record if its short name ends with any configured suffix **OR** its FQCN sits under any configured namespace prefix. The two signals are a union covering both fleet strategies. Defaults:

```neon
parameters:
auditModelNamespacePrefixes:
- 'App\Models\Audit' # entreezuil / ublgenie convention (incl. channel logs: AuthEventLog, SmsEventLog)
auditModelNameSuffixes:
- 'AuditLog' # kendo *AuditLog models, scattered across App\Models + App\Models\Central
```

A consumer whose audit models use a different family widens either list — for example, to bring a kendo-style channel-log pair (`AiOutboundLog`, `AiMcpLog`) into scope alongside the `*AuditLog` entity models:

```neon
parameters:
auditModelNameSuffixes:
- 'AuditLog'
- 'OutboundLog'
- 'McpLog'
```

Configuration expresses **patterns**, never enumerated class names — no consumer class name is ever hardcoded in the rule body, and a non-model class named `*AuditLog` (a DTO, a service) is excluded by the Eloquent `Model` type gate.

**Protections** — three checks fire independently (a model missing several yields several errors at the class line):

| Identifier | Fires when |
|---|---|
| `enforceAuditModelProtections.hasFactoryForbidden` | the model uses `HasFactory` (transitively — an inherited trait on an abstract base counts). A factory is a direct-insert path that bypasses the hash-chained audit writer. |
| `enforceAuditModelProtections.softDeletesForbidden` | the model uses `SoftDeletes`. Audit rows are append-only and never removed. |
| `enforceAuditModelProtections.updatedAtNotDisabled` | the model does not declare `public const UPDATED_AT = null`. The framework `Model` base sets `UPDATED_AT = 'updated_at'`, so a model that never overrides it keeps a mutable timestamp — an audit row is written once and never mutated. A model that disables timestamps wholesale (`public $timestamps = false;`) never writes `updated_at` at all and is recognised natively as compliant. |

Abstract intermediates (`abstract class BaseAuditLog`) are exempt — the concrete leaf carries any inherited violation.

**Migrating off the local arch test** — move the arch test's model-discovery convention into the parameters above, delete the local `HasFactory` / `SoftDeletes` / `updated_at` model checks, and the package rule becomes the single enforcement authority. (The append-only `update()` / `delete()` ban on `*Log` classes is a separate concern already covered by `LogRule`.) A `$timestamps = false` model needs no suppression — the rule recognises disabled-wholesale timestamps natively. A remaining genuine non-audit false positive is suppressed per-file via `ignoreErrors` keyed on the specific identifier, with a rationale comment:

```neon
parameters:
ignoreErrors:
-
identifier: enforceAuditModelProtections.hasFactoryForbidden
# seeded read-model projection, not an audit record; factory is test-only
path: app/Models/Audit/SomeProjectionLog.php
```

### Action namespace assumption

Expand Down
Loading
Loading