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 measured baseline is 83.92% (240/286 lines acrosssrc/), set 0.92 percentage points lower to absorb trivial fluctuation on equivalent-but-renamed code. Class coverage (0/6) and method coverage (39%) are intentionally unmeasured by the gate v1; per the issue's deliberation, line coverage is the right v1 signal and branch/method coverage is a follow-up after the line gate is bedded in. The 16-percentage-point gap to 100% audits as defensive guard clauses on unexpected node shapes (the kind of branch the issue itself flagged as "genuinely hard to fixture" —LogRule's static-call branch falls back when$node->classisExprrather thanName); a follow-up issue will audit and ratchet the threshold upward to 90%+. Versioning: none (pure CI/test-infra, no consumer-visible behaviour). Resolves issue #9. - CI: added Infection mutation testing gate, layered on top of the line-coverage gate. New
infection/infection ^0.32.7dev dependency,infection.json5config (@defaultmutator profile,src/source scope, fixtures stay out via PHPUnit's existing<source>block,--testsuite=Rules), and two new composer scripts:mutation(local,--threads=max --show-mutationsfor inspecting escaped mutants) andmutation:ci(CI:--threads=4 --no-progress --logger-github --min-msi=75 --min-covered-msi=75— GitHub annotations on escaped mutants surface inline in PR diffs). Two new CI steps after the coverage gate: Mutation testing and Upload mutation report (per-leginfection-php-${{ matrix.php }}artifact, 14-day retention).composer config allow-plugins.infection/extension-installer truewas set to permit the framework-adapter installer plugin. Initial thresholds: 75% MSI and 75% Covered Code MSI — measured baseline is 78.5% MSI (241 killed / 307 mutants, 100% Mutation Code Coverage), set 3.5 percentage points lower to absorb mutator-shape fluctuation on equivalent code. Same shape as the line-coverage gate: lock in current state, audit gaps, ratchet upward. The 22% surviving-mutant population audits as a mix of (a) genuinely-equivalent mutants the issue itself anticipated —mb_stripos↔striposon PSR-4 ASCII-only class names inLogRule, defensive guard inversions (LogicalNot/IfNegation) on early returns that filter the same nodes by either condition — and (b) genuinely-uncovered branch logic that warrants new fixtures. A follow-up issue will audit each survivor, kill where realistic,@infection-ignore-for-mutator-annotate where equivalent, and ratchet thresholds to the issue's target of 80% MSI / 90% Covered Code MSI. Versioning: none (pure CI/test-infra, no consumer-visible behaviour;infectionisrequire-devonly). Resolves issue #10.