Skip to content

Latest commit

 

History

History
77 lines (48 loc) · 24.6 KB

File metadata and controls

77 lines (48 loc) · 24.6 KB

Changelog

All notable changes to script-development/phpstan-warroom-rules are documented in this file.

The format follows Keep a Changelog, and the project adheres to Semantic Versioning.

Added

  • 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 before tagging — 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 before tagging — ISMS-0003 PR #7 commit f1d357b already closed ISMS's known violators; other consumer territories may carry undetected violators.

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

0.3.0 — 2026-05-13

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 extending App\Http\Resources\ResourceData that declare a non-empty EAGER_LOAD_COUNT or EAGER_LOAD_SUM constant but do not call validateRelationsLoaded() anywhere in their method bodies. Without the call, missing eager-load aggregates fail open as 0 / null instead 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 at db20ea9cf) — 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 the resourceDataBaseClass PHPStan parameter (default: App\Http\Resources\ResourceData); territories whose ResourceData lives elsewhere can override per consumer phpstan.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 is protected 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 at campaigns/phpstan-warroom-rules/2026-05-08-pre-cascade-audit-resource-data-validator-opt-in.md. Side observations: emmie uses App\Http\Resources\DTOResource (non-default base, rule non-applicable absent resourceDataBaseClass override); entreezuil has not adopted the ResourceData pattern (still on JsonResource despite ADR-0009 in CLAUDE.md, latent adoption debt); BIO operates dual-base (ResourceData + ComputedResourceData<TSource> per BIO sovereign ADR-0010). Sister extractions for the FormRequest toDto() 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.yml and release.yml to 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 the release.yml header 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 standard https://packagist.org/api/github push-event webhook (dev-* aliases on branch push, versioned releases on tag push via release.yml). Migration to Private Packagist would change ally-side Composer consumption (private repo URL + token in composer.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/CODEOWNERS routing 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 enabling require_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 shapes Model::destroy(...) and Model::forceDestroy(...) on Log-named classes. getNodeType() broadened from MethodCall::class to CallLike::class and processNode branches on MethodCall vs StaticCall. Both shapes emit the same logRule.logModification identifier so consumer phpstan.neon ignoreErrors entries cover the whole rule with one identifier (the previous rule's compliance teeth depended on delete/forceDelete instance shapes; on a non-soft-delete log model Model::destroy([1]) purges and Model::forceDestroy([1]) always purges — both slipped through). Versioning: Major-at-rule-level per ADR-0021 §Versioning; ships as v0.3.0. Cross-territory cascade audit (2026-05-13): 0 violators across kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator — campaign report at campaigns/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-side ignoreErrors migrations required. Resolves issue #4.
  • LogBuilderTruncateRule (BREAKING): new sibling rule to LogRule, sharing the logRule.logModification identifier so consumer phpstan.neon ignoreErrors entries cover the whole append-only doctrine with one entry. Flags Builder->truncate() calls where the fluent chain's most recent table() invocation targets a Log-named table (string-literal first argument containing 'log' / 'logs', case-insensitive substring match). Covers DB::table('logs')->truncate(), DB::connection('central')->table('logs')->truncate(), and $this->db->table('logs')->truncate() (instance-injected ConnectionInterface). Receiver detection is type-based (Illuminate\Database\Query\Builder OR Illuminate\Database\Eloquent\Builder subtype via ObjectType::isSuperTypeOf()) — mirrors EnforceAuditSnapshotOnRetryRule's ConnectionInterface pattern. 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 $table property (AuditLog::query()->truncate()) or via Eloquent's from() 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 a table()-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()), Eloquent from('logs') chains, and Model-property-driven tables — all would need value-flow or model-graph inspection; acceptable misses, rely on reviewer + consumer-side phpstan.neon ignoreErrors. Versioning: Major-at-rule-level per ADR-0021 §Versioning; collapses into the bundled v0.3.0 Major alongside the LogRule static-call expansion. Cross-territory cascade audit (2026-05-13): 0 violators across kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator — campaign report at campaigns/phpstan-warroom-rules/2026-05-13-pre-cascade-audit-log-builder-truncate.md. The truncate() shape proved genuinely uncommon across the fleet (~6 calls total across 2,500+ scanned PHP files; none against log-named tables); no consumer-side ignoreErrors migrations required. Resolves issue #8.
  • CI: added PHP 8.5 to the ci.yml and release.yml test 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 — the composer.json ^8.4 contractual minimum — covered. shivammathur/setup-php@v2 supports 8.5 since GA. Resolves issue #5.
  • CI: added line-coverage measurement and a threshold gate. ci.yml switches coverage: nonecoverage: pcov on 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) and coverage:check (runs bin/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 the Tests step: 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 across src/), 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->class is Expr rather than Name); 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.7 dev dependency, infection.json5 config (@default mutator 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-mutations for inspecting escaped mutants) and mutation: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-leg infection-php-${{ matrix.php }} artifact, 14-day retention). composer config allow-plugins.infection/extension-installer true was 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_striposstripos on PSR-4 ASCII-only class names in LogRule, 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; infection is require-dev only). Resolves issue #10.

0.2.0 — 2026-05-04

Added

  • EnforceAuditSnapshotOnRetryRule — flags App\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 via new ... / ->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\ConnectionInterface subtype) — replaces territory-specific property-name matching ($this->db vs $this->connection). Escape hatch: // @audit-snapshot-retry-safety: <rationale> marker preceding the transaction call.

Changed

  • PHP constraint: bumped composer.json php from ^8.3 to ^8.4. The package's Pint config (mb_str_functions: true) normalizes ltrim/trim calls to mb_ltrim/mb_trim, which are PHP 8.4+ functions. The new rule introduced the first mb_ltrim/mb_trim callsites; aligning the constraint with the formatter's actual output. All consuming territories already run PHP 8.4 — no real-world impact.
  • LogRule (BREAKING): extended FORBIDDEN_METHODS from ['delete', 'update'] to ['delete', 'forceDelete', 'forceDeleteQuietly', 'update']. On a SoftDeletes-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 adopt SoftDeletes. Static-call shapes (Model::destroy(), Model::forceDestroy(), DB::table('logs')->truncate()) remain out of scope — getNodeType() returns MethodCall::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-side phpstan.neon ignoreErrors per package convention. Versioning: per ADR-0021 §Versioning, this is a Major bump (new errors in code that previously passed); within 0.x this ships as v0.2.0.

0.1.1 — 2026-04-29

Changed

  • Compatibility: widened illuminate/* constraints from ^11.0 || ^12.0 to ^11.0 || ^12.0 || ^13.0 across 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.

0.1.0 — 2026-04-29

Added

  • EnforceActionTransactionsRule — flags App\Actions\* classes whose execute() performs ≥2 writes without ->transaction(). Doctrine: ADR-0011.
  • ForbidDatabaseManagerInActionsRule — flags App\Actions\* constructors that inject Illuminate\Database\DatabaseManager. Doctrine: ADR-0021 §Why ConnectionInterface.
  • ForbidAbortHelperRule — flags abort(), abort_if(), abort_unless() function calls. Doctrine: war-room §Explicit over implicit.
  • LogRule — flags update() / 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 of mixed.

Notes

  • Rules ported from emmie's backend/app/PHPStan/. The territory-specific Terminology exception in LogRule was dropped — per-territory false positives are now suppressed via consumer phpstan.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.