Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 25 Jun 13:03
· 2 commits to main since this release
d6a3fb6

Release-as-a-whole: MAJOR — ships four new rules (EnforceCurrentUserAttributeRule, ForbidEloquentMutationInControllersRule, EnforceAuditTransactionScopeRule, EnforceFormRequestToDtoRule) plus two folded no-op fixes (the NEON double-backslash defect that silenced EnforceFormRequestToDto + EnforceResourceDataValidatorOptIn for default-configured consumers — so EnforceResourceDataValidatorOptInRule, shipped in v0.3.0, has been a no-op on ^0.3, and its real surface also appears on bump). Unlike v0.3.0 (audited clean fleet-wide before tag), v0.4.0 is tagged known-dirty. The per-rule "pre-cascade audit" notes below move to per-territory Phase-B bump time, not before-tag: the ^0.{minor} caret means ^0.3 excludes 0.4.0, so tagging auto-adopts nobody — each consumer remediates and goes green on its own ^0.3 → ^0.4 bump PR (each carrying baseline-absorb for the other rules plus any login-handler ignoreErrors; see README §EnforceCurrentUserAttributeRule — false positives). Current-user remediation is already in review on its four territories: ublgenie #341, entreezuil #226, emmie #413, codebook #380. Phase B follows as per-territory war-room dispatches at our own pace.

Added

  • Tests: direct type-inference coverage for ConnectionTransactionReturnTypeExtension via a PHPStan\Testing\TypeInferenceTestCase (tests/Type/ConnectionTransactionReturnTypeExtensionTest.php + fixture tests/Fixtures/ConnectionTransactionReturnType/transaction-return-type.php). The extension previously had no direct test — it was only exercised implicitly by audit-snapshot rule fixtures, none of which asserted the resolved return type. The new fixture loads extension.neon (same config consumers register) and assertType()s the inferred type of $connection->transaction(...) for closures returning a constant scalar, an object/DTO, a nullable, an array shape, and a widened (non-constant) scalar — pinning that the extension forwards the closure acceptor's return type rather than mixed. Test-only; no consumer-facing surface. Closes Quartermaster F-2. Versioning: none (internal test coverage).
  • EnforceCurrentUserAttributeRule — flags calls to Request::user() / Auth::user() / auth()->user() inside classes in the App\Http\Controllers namespace. 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 at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the [0.4.0] release header) — 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<User> 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 at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the [0.4.0] release header) — 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 at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the [0.4.0] release header) — ISMS-0003 PR #7 commit f1d357b already closed ISMS's known violators; other consumer territories may carry undetected violators.
  • EnforceFormRequestToDtoRule — flags concrete classes extending Illuminate\Foundation\Http\FormRequest that neither declare nor inherit a toDto() method. Without the method, controllers hand $request->validated() arrays to Actions — untyped, key-renameable, and invisible to static analysis; the typed-DTO handoff is the ADR-0012 contract. Doctrine: ADR-0012 (FormRequest → DTO Flow). Identifier: enforceFormRequestToDto.missingToDtoMethod. Promoted from entreezuil's reflection-based Pest arch test (tests/Arch/FormRequestsTest.php, "form requests with mutation actions define toDto method") — the second instance of the "arch test detects misuse but not omission" enforcement shape under war-room enforcement queue #55 (Commander dispositioned the Phase-2 promotion 2026-05-07; war-room board WR-0066). Sister of EnforceResourceDataValidatorOptInRule (queue #55 instance 3, PR #20) — same opt-in-omission pedagogy, same parameterized-base shape. kendo carries only the weaker misuse-only form; the stronger entreezuil omission semantic is what ships here. Inheritance is matched via PHPStan reflection (FQCN ancestor traversal) — short-name collisions in unrelated namespaces do NOT match. The base FQCN is parameterizable via the formRequestBaseClass PHPStan parameter (default: Illuminate\Foundation\Http\FormRequest); territories can narrow the contract to a territory-local base per consumer phpstan.neon. Abstract classes are exempt (the per-territory BaseFormRequest intermediate is not a mutation request); inherited and trait-provided toDto() declarations satisfy the contract (mirroring the source-of-truth Pest test's method_exists() matcher). Legitimately DTO-less requests (entreezuil precedent: LoginRequest, whose auth flow calls AuthManager::attempt() directly) are suppressed per consumer phpstan.neon ignoreErrors keyed on the identifier — never by name inside the rule, per the package convention. Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has a concrete FormRequest without a toDto() method — read-only/query requests included until suppressed). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory, codebook at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the [0.4.0] release header) — entreezuil's Pest arch test already closed its own violators, but every other consumer territory enforces at most the weaker misuse-only shape and may carry undetected omissions. Sister extraction for the routes ->can() middleware omission shape (queue #55 instance #1, WR-0067) remains deferred to a separate dispatch.

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.

  • EnforceFormRequestToDtoRule / EnforceResourceDataValidatorOptInRule — corrected the shipped extension.neon parameter defaults from double-backslash single-quoted FQCNs ('Illuminate\\Foundation\\Http\\FormRequest', 'App\\Http\\Resources\\ResourceData') to single-backslash ('Illuminate\Foundation\Http\FormRequest', 'App\Http\Resources\ResourceData'). NEON only unescapes \\ inside double-quoted strings; in single quotes (and unquoted) \\ stays two literal characters, so the parameter decoded to a 4-segment double-backslash class name that matches no real class. The effect: ClassReflection::isSubclassOf() returned false for every analysed class and both rules were silent no-ops for any consumer registering extension.neon without an explicit parameter override — CI stayed green because every PHPUnit test, the coverage gate, and Infection construct the rule directly via the PHP ::class constructor default (PHP single-quoted strings do collapse \\), never exercising the NEON registration path. Verified empirically end-to-end: shipped default → [OK] No errors on the ViolatorRequest / ViolatorResource fixtures; single-backslash → fires with the exact expected message/line. The resourceDataBaseClass defect is pre-existing (shipped in PR #20, v0.3.0) — EnforceResourceDataValidatorOptInRule has been a no-op for default-configured consumers since release; fixed here in the same edit rather than deferred since EnforceFormRequestToDtoRule introduced the identical defect on the line above. Caught by the agent-review sweep on PR #33 (jasperboerhof BLOCKER). Regression-proofed by a container-resolved test per rule (testRuleResolvesFromExtensionNeonAndFires — resolves the rule from the PHPStan container via getAdditionalConfigFiles() + getByType(), exercising the NEON default and %parameter% wiring, then asserts the violator fires; both tests confirmed to fail when the double-backslash defect is reintroduced). An inline NEON-quoting warning comment now guards both defaults in extension.neon. Versioning: for EnforceFormRequestToDtoRule, this is part of the same [Unreleased] candidate-Major the rule's addition carries (the rule now actually fires in consumers). For EnforceResourceDataValidatorOptInRule, the v0.3.0 release that shipped the rule was a no-op for default consumers; restoring enforcement surfaces previously-undetected violations on ^0.3 consumers without a parameter override — the pre-cascade audit demanded for both rules must now treat the resource-data rule as effectively un-enforced until this fix ships.

Added

  • Tests: EnforceFormRequestToDtoRule gains two fixture-backed coverage additions closing documented-but-unpinned semantics: TraitProvidedToDtoRequest (a concrete request whose toDto() arrives via a trait — pins the trait leg of the method_exists() parity promise, which routes through PHPStan's hasNativeMethod() trait flattening) and TransitiveViolatorRequest (a concrete leaf extending the abstract AbstractBaseRequest with no toDto() anywhere in the chain — pins transitive-violation detection through an intermediate abstract layer, the inverse of the existing inherited-compliant case). Both raised in the PR #33 review (jasperboerhof MINOR + General-review concern). Test-only; no consumer-facing surface. Versioning: none (test coverage).

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).

Documentation

  • README: added a Production dependencies section documenting why the illuminate/* chain (database, contracts, cache, filesystem, log, mail) lives in require, not require-dev. The rules and ConnectionTransactionReturnTypeExtension reflect against Illuminate contracts/classes at analysis time, so they are genuine analysis-time (runtime-for-the-extension) dependencies; moving them to require-dev would break consumers analysing non-Laravel or partial trees. Documents the architectural intent (Sapper M1 Finding #4, Form A — keep + document, do not move). Versioning: none (docs only).