From e8a2aedb6a784eabaafe20579f808dde8a6aea6a Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Fri, 31 Jul 2026 17:09:12 +0200 Subject: [PATCH 1/5] Add PHPStan level 5 and the static analysis workflow --- .github/workflows/static-analysis.yml | 49 +++++++++++++++++++++++++++ phpstan.neon.dist | 15 ++++++++ 2 files changed, 64 insertions(+) create mode 100644 .github/workflows/static-analysis.yml create mode 100644 phpstan.neon.dist diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..4263385 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,49 @@ +# cspell:ignore shivammathur ramsey reqs +name: PHPStan + +on: + pull_request: + push: + branches: + - main + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP environment + uses: shivammathur/setup-php@v2 + with: + php-version: "8.0" + extensions: mbstring, intl + coverage: none + + - uses: ramsey/composer-install@v3 + with: + composer-options: "--ignore-platform-reqs --optimize-autoloader" + dependency-versions: highest + + - name: Restore PHPStan cache + uses: actions/cache/restore@v4 + with: + path: phpstan-cache + key: v1-phpstan-${{ runner.os }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + v1-phpstan-${{ runner.os }}-${{ github.ref_name }}- + v1-phpstan-${{ runner.os }}- + v1-phpstan- + + - name: Run PHPStan static analysis + run: composer test:analysis + + - name: Save PHPStan cache + uses: actions/cache/save@v4 + if: ${{ !cancelled() }} + with: + path: phpstan-cache + key: v1-phpstan-${{ runner.os }}-${{ github.ref_name }}-${{ github.run_id }} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..e03f2c9 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,15 @@ +includes: + - vendor/szepeviktor/phpstan-wordpress/extension.neon + +parameters: + phpVersion: 70400 + level: 5 + tmpDir: phpstan-cache + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + + paths: + - src + + scanDirectories: + - vendor/stellarwp/container-contract/src From 47e6e8c63dbb29e9924df9b60a22f3a35614249a Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 3 Aug 2026 13:19:25 +0200 Subject: [PATCH 2/5] Address review: stop the analysis job from drifting off the 7.4 floor - --ignore-platform-reqs also disabled the config.platform.php pin, which resolved dev dependencies requiring PHP 8.1+; waive only the ext-* requirements a static-analysis run genuinely has no use for. - Run on 7.4, the floor the pin already targets, instead of an EOL 8.0 that matches neither end of the test matrix. - Fail when src/ holds no PHP files: PHPStan 1.x exits 0 on nothing to analyse, so a moved source root would leave the check green forever. - Narrow the token, cancel superseded PR runs, and cap the job, matching the tests workflow. - Drop reportUnmatchedIgnoredErrors: false; with no ignores yet it does nothing except guarantee the first stale one never gets reported. - Key the cache off head_ref, which is a branch name on pull_request. --- .github/workflows/static-analysis.yml | 52 ++++++++++++++++++++++++--- phpstan.neon.dist | 3 +- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 4263385..eefcd88 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -6,44 +6,86 @@ on: push: branches: - main + workflow_dispatch: + +# This workflow only reads the repository. Narrow the token accordingly. +permissions: + contents: read + +# A superseded PR run is a result nobody is waiting on any more. Pushes to a +# long-lived branch are left alone so their history stays complete. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: phpstan: name: phpstan runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 1 + # The library's own floor, which is also what config.platform.php pins + # dependency resolution to. Matching the two means this job analyses the + # exact dependency tree a consumer on the minimum supported PHP gets. - name: Configure PHP environment uses: shivammathur/setup-php@v2 with: - php-version: "8.0" + php-version: "7.4" extensions: mbstring, intl coverage: none + # ext-* only, never the blanket --ignore-platform-reqs. The blanket form + # also switches off the config.platform.php pin, which resolves dev + # dependencies requiring PHP 8.1+ that cannot run here at all -- PHPStan + # would then check src/ against signatures from code this library can + # never load. wp-browser hard-requires ext-mysqli, ext-zip, and + # ext-fileinfo that a static-analysis run has no use for; those are what + # actually need waiving. - uses: ramsey/composer-install@v3 with: - composer-options: "--ignore-platform-reqs --optimize-autoloader" + composer-options: "--ignore-platform-req=ext-* --optimize-autoloader" dependency-versions: highest + # PHPStan 1.x prints "No files found to analyse" and still exits 0. If + # src/ ever moves out from under the paths setting, analysis would stop + # running while the check stayed green, and every later task's "keep + # test:analysis green" gate would silently become a no-op. + - name: Assert there is something to analyse + run: | + count=$(find src -name '*.php' -type f | wc -l) + if [ "${count}" -lt 1 ]; then + echo "phpstan.neon.dist points at src/, which holds no PHP files. Analysis would pass without analysing anything." >&2 + exit 1 + fi + + # Cache entries are immutable, so the write key carries the run id to keep + # it unique and restore-keys does the actual matching, longest prefix + # first. head_ref is the branch on a pull_request, where ref_name would be + # the far less legible "N/merge". - name: Restore PHPStan cache uses: actions/cache/restore@v4 with: path: phpstan-cache - key: v1-phpstan-${{ runner.os }}-${{ github.ref_name }}-${{ github.run_id }} + key: v1-phpstan-${{ runner.os }}-${{ github.head_ref || github.ref_name }}-${{ github.run_id }} restore-keys: | - v1-phpstan-${{ runner.os }}-${{ github.ref_name }}- + v1-phpstan-${{ runner.os }}-${{ github.head_ref || github.ref_name }}- v1-phpstan-${{ runner.os }}- v1-phpstan- - name: Run PHPStan static analysis run: composer test:analysis + # Saved even on failure: the result cache is per-file and records errors + # as legitimately as passes, so the next run still skips unchanged files. - name: Save PHPStan cache uses: actions/cache/save@v4 if: ${{ !cancelled() }} with: path: phpstan-cache - key: v1-phpstan-${{ runner.os }}-${{ github.ref_name }}-${{ github.run_id }} + key: v1-phpstan-${{ runner.os }}-${{ github.head_ref || github.ref_name }}-${{ github.run_id }} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e03f2c9..7f529a4 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,8 +5,9 @@ parameters: phpVersion: 70400 level: 5 tmpDir: phpstan-cache + # This library is called from untyped WordPress plugin code, so runtime type + # guards have to survive even where the PHPDoc says they cannot fail. treatPhpDocTypesAsCertain: false - reportUnmatchedIgnoredErrors: false paths: - src From 4e6c22d057f7c34f8e85a99b31bd4ac0bb1b6edd Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 3 Aug 2026 13:19:25 +0200 Subject: [PATCH 3/5] Plan: track widening PHPStan to test-support code as deferred item G --- docs/superpowers/plans/2026-07-31-plugin-absorber.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/superpowers/plans/2026-07-31-plugin-absorber.md b/docs/superpowers/plans/2026-07-31-plugin-absorber.md index ec4ba12..031f2f0 100644 --- a/docs/superpowers/plans/2026-07-31-plugin-absorber.md +++ b/docs/superpowers/plans/2026-07-31-plugin-absorber.md @@ -5636,6 +5636,10 @@ Recorded in the spec, deliberately not fixed in 1.0.0: - **E** — `Activation::maybe_run()` reads the option, runs the callback, then writes. Two simultaneous first requests can both run it. `add_option()` as an atomic claim would close it. - **F** — `Config::get_version()` is stored but never read. +- **G** — PHPStan analyses `src/` only. Hand-written test-support code is thin for now, and adding + `tests/` wholesale would drag in Codeception's generated actor and `WPTestCase` module magic — + a well-known source of level-5 false positives. Once real support classes accumulate, put + `tests/_support` alone under a second config rather than widening `paths`. ## Self-review From b5620f3164a6aa1332bb94a1f56211788b7e788a Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Thu, 6 Aug 2026 16:21:00 +0200 Subject: [PATCH 4/5] Raise PHPStan to level 8 Level 5 left coverage unused: the package analyses clean at 6, and 7 and 8 report the same single error, so 8 costs nothing beyond it. Level 9 is not taken -- its five findings are all in Sub_Plugin's untyped config reads, which are the runtime guards treatPhpDocTypesAsCertain: false exists to keep. --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7f529a4..cce3234 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,7 +3,7 @@ includes: parameters: phpVersion: 70400 - level: 5 + level: 8 tmpDir: phpstan-cache # This library is called from untyped WordPress plugin code, so runtime type # guards have to survive even where the PHPDoc says they cannot fail. From ebd45aab7cbdd0d4d0be814a01c232ab04bde195 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Tue, 11 Aug 2026 10:46:27 +0200 Subject: [PATCH 5/5] Add tests directory to PHPStan analysis and exclude generated files Updated the PHPStan configuration to include the 'tests' directory for analysis while excluding generated test-support files to prevent false positives. Also, added 'phpstan-cache' to the export-ignore list in .gitattributes. --- .gitattributes | 1 + docs/superpowers/plans/2026-07-31-plugin-absorber.md | 4 ---- phpstan.neon.dist | 10 ++++++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitattributes b/.gitattributes index 405a240..d151f84 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,4 +12,5 @@ /codeception.slic.yml export-ignore /cspell.json export-ignore /engineering-plan.md export-ignore +/phpstan-cache export-ignore /phpstan.neon.dist export-ignore diff --git a/docs/superpowers/plans/2026-07-31-plugin-absorber.md b/docs/superpowers/plans/2026-07-31-plugin-absorber.md index 031f2f0..ec4ba12 100644 --- a/docs/superpowers/plans/2026-07-31-plugin-absorber.md +++ b/docs/superpowers/plans/2026-07-31-plugin-absorber.md @@ -5636,10 +5636,6 @@ Recorded in the spec, deliberately not fixed in 1.0.0: - **E** — `Activation::maybe_run()` reads the option, runs the callback, then writes. Two simultaneous first requests can both run it. `add_option()` as an atomic claim would close it. - **F** — `Config::get_version()` is stored but never read. -- **G** — PHPStan analyses `src/` only. Hand-written test-support code is thin for now, and adding - `tests/` wholesale would drag in Codeception's generated actor and `WPTestCase` module magic — - a well-known source of level-5 false positives. Once real support classes accumulate, put - `tests/_support` alone under a second config rather than widening `paths`. ## Self-review diff --git a/phpstan.neon.dist b/phpstan.neon.dist index cce3234..ddc2b46 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,16 @@ parameters: paths: - src + - tests + + excludePaths: + analyse: + # Codeception generates the actor's trait from the suite's module list. + # It is not committed, so on a fresh checkout it does not exist at all; + # the actor that uses it is excluded alongside it. Everything else under + # tests/ is hand-written and is analysed. + - tests/_support/_generated/* + - tests/_support/UnitTester.php scanDirectories: - vendor/stellarwp/container-contract/src