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/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..eefcd88 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,91 @@ +# cspell:ignore shivammathur ramsey reqs +name: PHPStan + +on: + pull_request: + 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: "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-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.head_ref || github.ref_name }}-${{ github.run_id }} + restore-keys: | + 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.head_ref || github.ref_name }}-${{ github.run_id }} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..ddc2b46 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,26 @@ +includes: + - vendor/szepeviktor/phpstan-wordpress/extension.neon + +parameters: + phpVersion: 70400 + 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. + treatPhpDocTypesAsCertain: false + + 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