Releases: script-development/phpstan-warroom-rules
Release list
v0.6.0
Release-as-a-whole: backward-compatible MINOR — one new optional PHPStan parameter (formRequestToDtoExemptClasses, default []) on EnforceFormRequestToDtoRule. Default-empty ⇒ zero new errors in existing consumers; a consumer adopts on its own ^0.5 → ^0.6 pin bump. Seed: war-room enforcement queue #131 — a class-keyed exemption surface so a territory can retire a duplicate local FormRequestsTest arch test and consolidate its exempt list into package config.
Added
EnforceFormRequestToDtoRule— new optionalformRequestToDtoExemptClassesPHPStan parameter (default[]): a list of fully-qualified class names to exempt from thetoDto()contract, matched by exact FQCN. This is the class-keyed alternative to the existing per-filephpstan.neonignoreErrorssuppression path (identifier +pathkeyed) — predictable across file moves and, crucially, it ports a retiring local FormRequest→DTO Pest arch test's FQCN exemption list into package config 1:1. Wired throughextension.neon(formRequestToDtoExemptClasses: []parameter +listOf(string())schema +exemptClasses: %formRequestToDtoExemptClasses%on the rule's service registration), mirroring theformRequestBaseClassprecedent. No class name is ever hardcoded in the rule body — a consumer-supplied FQCN list is config, not a rule-body literal, so the package's "never by name inside the rule" convention is preserved. Seed: war-room enforcement queue #131 —EnforceFormRequestToDtoRulenow duplicates territory-localFormRequestsTestarch tests (entreezuil first live case, PR #274), enforcing the same invariant twice with two exemption ledgers to keep in sync; the class-keyed param lets a territory retire its local arch test and consolidate its exempt list into package config (that retirement is a separate follow-up, not this change). README documents the param + a "Retiring a local FormRequest→DTO arch test" migration recipe. Versioning: backward-compatible MINOR (new optional parameter, default empty ⇒ zero new errors in existing consumers — NOT a candidate-major; the default-empty list means no consumer sees behaviour change until it opts in). Released as v0.6.0.
v0.5.0
Release-as-a-whole: candidate MAJOR (pre-1.0 minor bump — ^0.4 → ^0.5) — ships two new rules (ForbidHttpExceptionInActionsRule, ForbidResourceWrappedInJsonResponseRule) from the Commander's review of emmie PR #481 (war-room enforcement queue #123 + #124). Both surface new errors in already-clean code wherever a consumer violates, so each consumer adopts on its own ^0.4 → ^0.5 bump PR (the ^0.{minor} caret means ^0.4 excludes 0.5.0 — tagging auto-adopts nobody). Blast radius (surveyed 2026-06-25 origin/development): ZERO violators on emmie + kendo for both rules (the #481 offender is on its branch, not merged) — the per-territory bump is expected clean save for un-merged branch work.
Added
ForbidHttpExceptionInActionsRule— flagsthrowstatements insideApp\Actions\*classes whose thrown expression's type is a subtype ofSymfony\Component\HttpKernel\Exception\HttpExceptionInterface(theHttpExceptionfamily —HttpExceptionitself plus every subclass:NotFoundHttpException,AccessDeniedHttpException,UnprocessableEntityHttpException, …). HTTP status concerns belong to the HTTP layer; an Action that throws a 422 has reached past its boundary into transport. A uniqueness rule belongs in the FormRequest; a domain failure throws a custom domain exception the renderer maps to a status. Identifier:forbidHttpExceptionInActions.httpExceptionInAction. Doctrine: war-room §Architectural Principles — Explicit over implicit (#1) + Form Request → DTO → Action pipeline (#3). Type-aware sibling ofForbidAbortHelperRule(which bans theabort()helper family whose own message recommendsthrow new HttpException— correct for controllers, wrong for Actions): this rule closes the matching gap on the directthrow new HttpException(...)form inside Actions, catching subclass throws, fully-qualified throws with nouseimport, and typed-value throws that an import-checking arch test would miss.Illuminate\Validation\ValidationExceptionis explicitly OUT of scope — Actions legitimately thrownew ValidationException($validator)for stateful / cross-field validation that cannot live in a static FormRequest; it is not a member of the SymfonyHttpExceptionfamily, so the type gate never fires on it. Action-namespace gate mirrorsForbidDatabaseManagerInActionsRule(App\Actionsprefix via$scope->getNamespace()+str_starts_with). Out of scope: non-App\Actions\*namespaces (controllers, FormRequests, exception renderers, middleware all legitimately raise HTTP-layer exceptions); theabort()helper family (covered byForbidAbortHelperRule). Seed: Commander review of emmie PR #481 —CreateLocationEmailActionthrewHttpException(422, …)for an "override already exists" uniqueness check (war-room enforcement queue #123). Blast radius (surveyed 2026-06-25origin/development): ZERO raw-HttpException-in-Action on emmie + kendo (the #481 offender is on its branch, not merged) — the rule lands green and red-flags #481 at CI once enabled. Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has an Action throwing an HTTP-layer exception). Pre-cascade audit required per consumer at Phase-B bump (^0.4 → ^0.5); the emmie + kendo survey shows zero current violators, so the cascade is expected clean fleet-wide save for any un-merged branch work.ForbidResourceWrappedInJsonResponseRule— flagsresponse()->json($payload, …)andnew JsonResponse($payload, …)insideApp\Http\Controllers\*classes only when$payload's resolved type is a subtype ofIlluminate\Http\Resources\Json\JsonResource. AJsonResourceis already aResponsable— Laravel serializes it to a JSON response on its own; wrapping one in an explicit JSON response double-wraps the payload and discards the resource's own response shaping. Return the resource directly instead:return XxxResource::fromModel($model);(HTTP 200). Identifier:forbidResourceWrappedInJsonResponse.resourceWrapped. Doctrine: war-room §Architectural Principles — Explicit over implicit (#1) + ADR-0009 (Unified ResourceData Pattern — resources own their own response serialization). Type-awareness is mandatory: a blanket string-ban onresponse()->json(...)would false-positive on the overwhelmingly common legitimate sites that wrap a plain array / DTO / scalar / message envelope, and onresponse()->json(null, 204)(a 2026-06-25 fleet survey sized ~24 emmie + ~43 kendoresponse()->json/JsonResponsesites, almost all legitimate non-Resource payloads — a blanket ban would be ~67 false positives). Two AST shapes inspected: (1)MethodCallnamedjsonwhose receiver is theresponse()helperFuncCall(AST-shape match — the helper'sResponseFactoryreturn type is unloaded in stub-only analysis environments, mirroring howEnforceCurrentUserAttributeRulematches theauth()helper); (2)New_ofIlluminate\Http\JsonResponse(FQCN via$scope->resolveName()). Named-envelope edge (decided: EXCLUDE): a resource (or resource collection) nested under a named array key — e.g. emmieRegistrationBroadcastController:28's['registrations' => …Resource::collect(...)]— is a deliberate response envelope, not a bare double-wrap; the first argument is anArray_whose type isarray<…>, not aJsonResourcesubtype, so the type gate naturally lets it through. Theresponse()->json($resource, 201)status-override form still fires (the wrap is the violation, not the status; resource-with-non-200 is an unrelated future investigation). Controller-namespace gate mirrorsForbidEloquentMutationInControllersRule. Out of scope: non-App\Http\Controllers\*namespaces; non-JsonResource payloads; resources nested in any envelope. Seed: Commander review of emmie PR #481 —EmailController::storereturnedresponse()->json(EmailResource::fromModel($email), 201)while siblingupdate()correctly returned the Resource directly (war-room enforcement queue #124). Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a controller wraps a resource in an explicit JSON response). Pre-cascade audit required per consumer at Phase-B bump (^0.4 → ^0.5); the 2026-06-25 survey found ~0 current violators on emmie + kendodevelopment(the #481 offender is on its branch).
v0.4.0
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
ConnectionTransactionReturnTypeExtensionvia aPHPStan\Testing\TypeInferenceTestCase(tests/Type/ConnectionTransactionReturnTypeExtensionTest.php+ fixturetests/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 loadsextension.neon(same config consumers register) andassertType()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 thanmixed. Test-only; no consumer-facing surface. Closes Quartermaster F-2. Versioning: none (internal test coverage). EnforceCurrentUserAttributeRule— flags calls toRequest::user()/Auth::user()/auth()->user()inside classes in theApp\Http\Controllersnamespace. 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 viaCallLikeregistration (mirrorsLogRulev0.3.0 shape):MethodCallonIlluminate\Http\Requestsubtype receiver (type-based viaObjectType::isSuperTypeOf());MethodCallwhose receiver is aFuncCall('auth')(AST-shape match — the helper's return type is unloaded in stub-only environments);StaticCallresolving toIlluminate\Support\Facades\Auth(FQCN comparison via$scope->resolveName()). Scoped to controllers via theApp\Http\Controllersnamespace prefix ($scope->getNamespace()+str_starts_with) — mirrorsForbidEloquentMutationInControllersRuleand the canonical "controllers are identified by theApp\Http\Controllersnamespace" convention. FormRequest (App\Http\Requests, where$this->user()is canonical because container-attribute injection does not apply toFormRequest::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 $clientresolves via client guard,#[CurrentUser] User $uservia user guard — verified in emmie'sClientController::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/*OrCreatevariants — 24-method blocklist) onIlluminate\Database\Eloquent\Modelsubclasses andIlluminate\Database\Eloquent\Builderchains when the call site is inside anApp\Http\Controllers\*class (including sub-namespaces like kendo'sApp\Http\Controllers\Central\*, matched viastr_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 everyClassMethodbody collectingMethodCall+StaticCallnodes → forMethodCall, fire ifObjectType::isSuperTypeOf()againstModelORBuildermatches the receiver type and the method name is in the blocklist; forStaticCall, fire ifScope::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 becauseObjectTypematchesBuilder<User>as a subtype of the unparameterizedBuildercleanly, no brittle generic introspection needed. Supersedes the consumer-side string-match Pest arch tests in kendo (backend/tests/Arch/ControllersTest.phpcontrollers must not call Eloquent write methods directly), ublgenie + entreezuil (tests/Arch/ControllersTest.phpof the same shape), and the bridge subset in ISMS (backend/tests/Architecture/ControllerCurrentUserTest.phpfrom PR #10, 2026-05-28). The string-match shape catches->save(,->update([,->delete(,->forceDelete(but cannot discriminateModel::create()fromResponse::create(),Collection::push()fromModel::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,*Quietlyvariants, 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(),*Quietlyvariants) 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/Filesystemand theirIlluminate\Support\Facades\*counterparts, mutation methods only) insidetransaction(...)closures inApp\Actions\*classes. Identifier:enforceAuditTransactionScope.nonTransactionalMutationInClosure. Doctrine: ADR-0029 §Decision rule 3. Seed: ISMS-0003 PR #7 commitf1d357b(2026-05-28) — three Auth Actions (AuthenticateWorkerAction,VerifyTwoFactorChallengeAction,LogoutWorkerAction) mutatedStatefulGuard+Sessionstate inside the transaction closure before the audit row write; an audit-write failure would have rolled back the audit row while leavi...
v0.3.0
Release-as-a-whole: MAJOR — collapses three rule-level contractual widenings into a single Major bump per ADR-0021 §Versioning. Each rule's pre-cascade audit returned 0 violators across all 5 consumer territories (kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator), so the Major represents the contract change, not empirical violation count. Consumers upgrading from ^0.2 to ^0.3 accept the broader rule contracts whether or not their existing code trips them. Phase A pin sweep (^0.1.x → ^0.2) closed pre-release — all four laggard consumers (kendo, entreezuil, emmie, BIO) bumped between 2026-05-06 and 2026-05-08 via independent dispatches; verified by 4-territory Medic wave 2026-05-13 (all no-op, all composer phpstan clean against EnforceAuditSnapshotOnRetryRule). Phase B pin sweep (^0.2 → ^0.3) follows post-tag as a separate war-room dispatch.
Added
EnforceResourceDataValidatorOptInRule— flags classes extendingApp\Http\Resources\ResourceDatathat declare a non-emptyEAGER_LOAD_COUNTorEAGER_LOAD_SUMconstant but do not callvalidateRelationsLoaded()anywhere in their method bodies. Without the call, missing eager-load aggregates fail open as0/nullinstead of throwing — silently re-introducing the silent-zero bug closed by kendo PR #1079 (KD-0494). Doctrine: ADR-0009 §EAGER_LOAD validator opt-in. Identifier:enforceResourceDataValidatorOptIn.missingValidatorCall. Promoted from kendo PR #1084's Pest arch test (Armorer, merged 2026-05-07 atdb20ea9cf) — the third instance of the "arch test detects misuse but not omission" enforcement shape, dispositioned for Phase-2 promotion under war-room enforcement queue #55 by the Commander on 2026-05-07. Inheritance is matched via PHPStan reflection (FQCN ancestor traversal) — short-name collisions in unrelated namespaces do NOT match. The base FQCN is parameterizable via theresourceDataBaseClassPHPStan parameter (default:App\Http\Resources\ResourceData); territories whoseResourceDatalives elsewhere can override per consumerphpstan.neon. Compliant call shapes:self::validateRelationsLoaded($model),static::validateRelationsLoaded($model),$this->validateRelationsLoaded($model)(instance form accepted for liberal compatibility with the source-of-truth Pest matcher, even though the base method isprotected static). Empty-array constants (EAGER_LOAD_COUNT = []) do NOT fire — they are no-ops. Versioning: Minor-at-rule-level, collapses into the bundled v0.3.0 Major per ADR-0021 §Versioning. Cross-territory cascade audit (2026-05-08): 0 violators across emmie, kendo, entreezuil, ublgenie, brick-inventory-orchestrator — campaign report atcampaigns/phpstan-warroom-rules/2026-05-08-pre-cascade-audit-resource-data-validator-opt-in.md. Side observations: emmie usesApp\Http\Resources\DTOResource(non-default base, rule non-applicable absentresourceDataBaseClassoverride); entreezuil has not adopted theResourceDatapattern (still onJsonResourcedespite ADR-0009 in CLAUDE.md, latent adoption debt); BIO operates dual-base (ResourceData+ComputedResourceData<TSource>per BIO sovereign ADR-0010). Sister extractions for the FormRequesttoDto()omission shape (queue #55 instance #2) and the routes->can()middleware omission shape (queue #55 instance #1) are deferred to separate dispatches.
Security
- Pinned all GitHub Actions references in
ci.ymlandrelease.ymlto commit SHAs with# v<MAJOR>comments for Dependabot tag-tracking. Closes Sapper M1 Finding #3 (supply-chain forward-compatibility before potential Packagist OIDC migration). Versioning: none (CI workflow change, no consumer-facing surface).
Changed
- Doctrine: corrected publish-channel framing in
CLAUDE.md(L11 and the Release process section) and therelease.ymlheader comment. Public packagist.org has no OIDC Trusted Publishing option today — OIDC is a Private Packagist–only feature (packagist/artifact-publish-github-action, GA February 2026). The package's actual publish channel is the standardhttps://packagist.org/api/githubpush-event webhook (dev-*aliases on branch push, versioned releases on tag push viarelease.yml). Migration to Private Packagist would change ally-side Composer consumption (private repo URL + token incomposer.json) and is a commercial decision; tracking continues on Issue #11. Closes Sapper M1 Finding #2 (doctrine drift on publish channel) and resolves Issue #11 audit. Versioning: none (doctrine alignment, no consumer-visible behaviour). - Governance: added
.github/CODEOWNERSrouting all changes to@script-development/phpstan-warroom-admins. A separate rule-authors team is intentionally not split out today — the admins team and the rule-design reviewer set are identical at the current shop size; revisit if the contributor base grows or rule-design review becomes a distinct concern from operational repo administration. Pairs with branch-protection update enablingrequire_code_owner_reviews=true. Closes Sapper M1 Finding #5 (no CODEOWNERS file). Versioning: none (governance change, no consumer-visible surface). LogRule(BREAKING): extended to cover the static-call shapesModel::destroy(...)andModel::forceDestroy(...)on Log-named classes.getNodeType()broadened fromMethodCall::classtoCallLike::classandprocessNodebranches onMethodCallvsStaticCall. Both shapes emit the samelogRule.logModificationidentifier so consumerphpstan.neonignoreErrorsentries cover the whole rule with one identifier (the previous rule's compliance teeth depended ondelete/forceDeleteinstance shapes; on a non-soft-delete log modelModel::destroy([1])purges andModel::forceDestroy([1])always purges — both slipped through). Versioning: Major-at-rule-level per ADR-0021 §Versioning; ships asv0.3.0. Cross-territory cascade audit (2026-05-13): 0 violators across kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator — campaign report atcampaigns/phpstan-warroom-rules/2026-05-13-pre-cascade-audit-log-rule-static-call.md. The static-call shape proved cleaner than v0.2.0's instance-call expansion (which surfaced 1 operational-log false positive in ublgenie); no consumer-sideignoreErrorsmigrations required. Resolves issue #4.LogBuilderTruncateRule(BREAKING): new sibling rule toLogRule, sharing thelogRule.logModificationidentifier so consumerphpstan.neonignoreErrorsentries cover the whole append-only doctrine with one entry. FlagsBuilder->truncate()calls where the fluent chain's most recenttable()invocation targets a Log-named table (string-literal first argument containing'log'/'logs', case-insensitive substring match). CoversDB::table('logs')->truncate(),DB::connection('central')->table('logs')->truncate(), and$this->db->table('logs')->truncate()(instance-injectedConnectionInterface). Receiver detection is type-based (Illuminate\Database\Query\BuilderORIlluminate\Database\Eloquent\Buildersubtype viaObjectType::isSuperTypeOf()) — mirrorsEnforceAuditSnapshotOnRetryRule'sConnectionInterfacepattern. The Eloquent\Builder receiver branch covers the rare-but-coherent$eloquentBuilder->table('logs')->truncate()shape; Eloquent chains that set the table via the Model's$tableproperty (AuditLog::query()->truncate()) or via Eloquent'sfrom()vocabulary (AuditLog::query()->from('logs')->truncate()) are an acceptable miss in the same family as variable table names — the table name does not appear as atable()-call string-literal in the chain. Doctrine: ADR-0001 §Append-only —truncate()is the bluntest delete and bypasses Eloquent events, observers, and audit triggers entirely. Out of scope: variable table names ($t = 'logs'; DB::table($t)->truncate()), Eloquentfrom('logs')chains, and Model-property-driven tables — all would need value-flow or model-graph inspection; acceptable misses, rely on reviewer + consumer-sidephpstan.neonignoreErrors. Versioning: Major-at-rule-level per ADR-0021 §Versioning; collapses into the bundled v0.3.0 Major alongside theLogRulestatic-call expansion. Cross-territory cascade audit (2026-05-13): 0 violators across kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator — campaign report atcampaigns/phpstan-warroom-rules/2026-05-13-pre-cascade-audit-log-builder-truncate.md. Thetruncate()shape proved genuinely uncommon across the fleet (~6 calls total across 2,500+ scanned PHP files; none against log-named tables); no consumer-sideignoreErrorsmigrations required. Resolves issue #8.- CI: added PHP 8.5 to the
ci.ymlandrelease.ymltest matrices alongside 8.4 (['8.4']→['8.4', '8.5']). PHP 8.5.0 was released 2025-11-20; the war-room dev environment already runs 8.5.5 locally, so PRs were getting ad-hoc 8.5 coverage during pre-push but no CI signal. Adding (rather than replacing) keeps 8.4 — thecomposer.json^8.4contractual minimum — covered.shivammathur/setup-php@v2supports 8.5 since GA. Resolves issue #5. - CI: added line-coverage measurement and a threshold gate.
ci.ymlswitchescoverage: none→coverage: pcovon both 8.4 and 8.5 matrix legs (PCOV is line-coverage-only and faster than Xdebug — debugger features aren't needed). New composer scripts:test:coverage(runs PHPUnit with--coverage-clover=build/logs/clover.xml --coverage-text) andcoverage:check(runsbin/coverage-check.php, a standalone clover parser — no extra runtime dependency added to a static-analysis package for a single CI gate). Two new CI steps replace theTestsstep: Tests with coverage and Coverage threshold gate. Clover XML is uploaded as a per-leg artifact (clover-php-${{ matrix.php }}, 14-day retention) so reviewers can inspect uncovered lines without spelunking through workflow logs. Initial threshold: 83% — the...
v0.2.0
Added
EnforceAuditSnapshotOnRetryRule— flagsApp\Actions\*classes whose constructor injects an entity audit logger and whose$connection->transaction(...)calls do not begin with an in-memory state reset ($model->refresh(), fresh fetch via->newQuery()->findOrFail(...)/->fresh(), or fresh instantiation vianew .../->newInstance()). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. Identifier:enforceAuditSnapshotOnRetry.firstStatementMustResetState. Promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). Receiver detection is type-based (Illuminate\Database\ConnectionInterfacesubtype) — replaces territory-specific property-name matching ($this->dbvs$this->connection). Escape hatch:// @audit-snapshot-retry-safety: <rationale>marker preceding the transaction call.
Changed
- PHP constraint: bumped
composer.jsonphpfrom^8.3to^8.4. The package's Pint config (mb_str_functions: true) normalizesltrim/trimcalls tomb_ltrim/mb_trim, which are PHP 8.4+ functions. The new rule introduced the firstmb_ltrim/mb_trimcallsites; aligning the constraint with the formatter's actual output. All consuming territories already run PHP 8.4 — no real-world impact. LogRule(BREAKING): extendedFORBIDDEN_METHODSfrom['delete', 'update']to['delete', 'forceDelete', 'forceDeleteQuietly', 'update']. On aSoftDeletes-bearing model->delete()is a no-op against the underlying row and->forceDelete()is the only call that actually purges; the rule's compliance teeth previously rested on the migration-time convention that audit-log models never adoptSoftDeletes. Static-call shapes (Model::destroy(),Model::forceDestroy(),DB::table('logs')->truncate()) remain out of scope —getNodeType()returnsMethodCall::class, and static-call coverage is tracked as issue #4. Origin: issue #1, surfaced by ally review on Back-to-code/ublgenie-app#163. Pre-cascade audit across emmie, kendo, entreezuil, ublgenie surfaced one new violation:ublgenie/app/Actions/DeleteBranch.php:56(InvoiceLog::query()->whereIn(...)->forceDelete()) — operational/processing log, not an audit log; migrates to consumer-sidephpstan.neonignoreErrorsper package convention. Versioning: per ADR-0021 §Versioning, this is a Major bump (new errors in code that previously passed); within 0.x this ships asv0.2.0.
v0.1.1
Changed
- Compatibility: widened
illuminate/*constraints from^11.0 || ^12.0to^11.0 || ^12.0 || ^13.0across the five required packages (database,contracts,cache,filesystem,log,mail). Surfaced during ADR-0021 cascade onto entreezuil (Laravel 13). No behavioral change — the package's PHPStan rules reason about class names that are stable across Laravel 11/12/13. Forward-looking: removes the constraint as a future cascade blocker.
v0.1.0
Added
EnforceActionTransactionsRule— flagsApp\Actions\*classes whoseexecute()performs ≥2 writes without->transaction(). Doctrine: ADR-0011.ForbidDatabaseManagerInActionsRule— flagsApp\Actions\*constructors that injectIlluminate\Database\DatabaseManager. Doctrine: ADR-0021 §Why ConnectionInterface.ForbidAbortHelperRule— flagsabort(),abort_if(),abort_unless()function calls. Doctrine: war-room §Explicit over implicit.LogRule— flagsupdate()/delete()calls on classes whose name contains"Log"or"logs". Doctrine: ADR-0001 §Append-only.ConnectionTransactionReturnTypeExtension— resolves$connection->transaction(fn () => $foo)to the closure's return type instead ofmixed.
Notes
- Rules ported from emmie's
backend/app/PHPStan/. The territory-specificTerminologyexception inLogRulewas dropped — per-territory false positives are now suppressed via consumerphpstan.neon ignoreErrors. - Test coverage is smoke-level for v0.1.0; full matrix for
EnforceActionTransactionsRule(non-DB property exclusions, nested closure transaction detection, full 18-method write list) lands in a follow-up. - Action namespace assumption: rules that scope to Actions match
App\Actions\*. Lift to a parameter when a non-conforming territory onboards.