Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/php-static-analysis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: PHPStan Static Analysis

on:
# PHPStan testing was introduced in @todo.
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment contains a TODO placeholder "@todo" that should be replaced with the actual version number when PHPStan testing is introduced

Suggested change
# PHPStan testing was introduced in @todo.
# Run PHPStan testing on pushes to the following branches and tags.

Copilot uses AI. Check for mistakes.
push:
branches:
- trunk
- '[7-9].[0-9]'
tags:
- '[7-9].[0-9]'
- '[7-9]+.[0-9].[0-9]+'
pull_request:
branches:
- trunk
- '[7-9].[0-9]'
paths:
# This workflow only scans PHP files.
- '**.php'
# These files configure Composer. Changes could affect the outcome.
- 'composer.*'
# These files configure PHPStan. Changes could affect the outcome.
- 'phpstan.neon.dist'
- 'tests/phpstan/base.neon'
# Confirm any changes to relevant workflow files.
- '.github/workflows/php-static-analysis.yml'
- '.github/workflows/reusable-php-static-analysis.yml'
workflow_dispatch:

# Cancels all previous workflow runs for pull requests that have not completed.
concurrency:
# The concurrency group contains the workflow name and the branch name for pull requests
# or the commit hash for any other events.
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true

# Disable permissions for all available scopes by default.
# Any needed permissions should be configured at the job level.
permissions: {}

jobs:
# Runs PHPStan Static Analysis.
phpstan:
name: PHP static analysis
uses: ./.github/workflows/reusable-php-static-analysis.yml
permissions:
contents: read
if: ${{ github.repository == 'WordPress/wordpress-develop' || ( github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' ) }}

slack-notifications:
name: Slack Notifications
uses: ./.github/workflows/slack-notifications.yml
permissions:
actions: read
contents: read
needs: [ phpstan ]
if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }}
with:
calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }}
secrets:
SLACK_GHA_SUCCESS_WEBHOOK: ${{ secrets.SLACK_GHA_SUCCESS_WEBHOOK }}
SLACK_GHA_CANCELLED_WEBHOOK: ${{ secrets.SLACK_GHA_CANCELLED_WEBHOOK }}
SLACK_GHA_FIXED_WEBHOOK: ${{ secrets.SLACK_GHA_FIXED_WEBHOOK }}
SLACK_GHA_FAILURE_WEBHOOK: ${{ secrets.SLACK_GHA_FAILURE_WEBHOOK }}

failed-workflow:
name: Failed workflow tasks
runs-on: ubuntu-24.04
permissions:
actions: write
needs: [ slack-notifications ]
if: |
always() &&
github.repository == 'WordPress/wordpress-develop' &&
github.event_name != 'pull_request' &&
github.run_attempt < 2 &&
(
contains( needs.*.result, 'cancelled' ) ||
contains( needs.*.result, 'failure' )
)

steps:
- name: Dispatch workflow run
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
retries: 2
retry-exempt-status-codes: 418
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'failed-workflow.yml',
ref: 'trunk',
inputs: {
run_id: `${context.runId}`,
}
});
109 changes: 109 additions & 0 deletions .github/workflows/reusable-php-static-analysis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
##
# A reusable workflow that runs PHP Static Analysis tests.
##
name: PHP Static Analysis

on:
workflow_call:
inputs:
php-version:
description: 'The PHP version to use.'
required: false
type: 'string'
default: 'latest'

# Disable permissions for all available scopes by default.
# Any needed permissions should be configured at the job level.
permissions: {}

jobs:
# Runs PHP static analysis tests.
#
# Violations are reported inline with annotations.
#
# Performs the following steps:
# - Checks out the repository.
# - Sets up PHP.
# - Logs debug information.
# - Installs Composer dependencies.
# - Configures caching for PHP static analysis scans.
# - Make Composer packages available globally.
# - Runs PHPStan static analysis (with Pull Request annotations).
# - Saves the PHPStan result cache.
# - Ensures version-controlled files are not modified or deleted.
phpstan:
name: Run PHP static analysis
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 20

steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: '.nvmrc'
cache: npm

- name: Set up PHP
uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3
with:
php-version: ${{ inputs.php-version }}
coverage: none
tools: cs2pr

# This date is used to ensure that the Composer cache is cleared at least once every week.
# http://man7.org/linux/man-pages/man1/date.1.html
- name: "Get last Monday's date"
id: get-date
run: echo "date=$(/bin/date -u --date='last Mon' "+%F")" >> "$GITHUB_OUTPUT"

- name: General debug information
run: |
npm --version
node --version
composer --version

# Since Composer dependencies are installed using `composer update` and no lock file is in version control,
# passing a custom cache suffix ensures that the cache is flushed at least once per week.
- name: Install Composer dependencies
uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # v3.1.1
with:
custom-cache-suffix: ${{ steps.get-date.outputs.date }}

- name: Make Composer packages available globally
run: echo "${PWD}/vendor/bin" >> "$GITHUB_PATH"

- name: Install npm dependencies
run: npm ci

- name: Build WordPress
run: npm run build:dev

- name: Cache PHP Static Analysis scan cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: .cache # This is defined in the base.neon file.
key: "phpstan-result-cache-${{ github.run_id }}"
restore-keys: |
phpstan-result-cache-

- name: Run PHP static analysis tests
id: phpstan
run: phpstan analyse -vvv --error-format=checkstyle | cs2pr

- name: "Save result cache"
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
if: ${{ !cancelled() }}
with:
path: .cache
key: "phpstan-result-cache-${{ github.run_id }}"

- name: Ensure version-controlled files are not modified or deleted
run: git diff --exit-code
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ wp-tests-config.php
/gutenberg
/tests/phpunit/build
/wp-cli.local.yml
/phpstan.neon
/jsdoc
/composer.lock
/vendor
Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"squizlabs/php_codesniffer": "3.13.5",
"wp-coding-standards/wpcs": "~3.3.0",
"phpcompatibility/phpcompatibility-wp": "~2.1.3",
"phpstan/phpstan": "~2.1.33",
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version constraint "~2.1.33" is very specific and may cause issues. The tilde operator (~) for a three-part version like 2.1.33 means ">=2.1.33 <2.2.0". This constraint locks to a specific patch version which may not exist or may prevent receiving important bug fixes. Consider using "^2.1" (which means ">=2.1.0 <3.0.0") or "~2.1.0" (which means ">=2.1.0 <2.2.0") instead to allow flexibility for patch updates while staying within the same minor version

Suggested change
"phpstan/phpstan": "~2.1.33",
"phpstan/phpstan": "~2.1.0",

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@westonruter what are your thoughts on this one?

I definitely think we should pin at the version we commit, but I wouldn't want to Semver because contextually "nonbreaking enhancements" are breaking from an implementation POV if they create a new quality gate.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me. So switching to ~2.1.0 would keep it at 2.1.x. This seems necessary because there is no composer.lock, which actually is curious since we package-lock.json. If we had a composer.lock then we'd be free to use ^2.1. But I suppose can't use it because of the different versions of PHP which may end up getting used when doing composer install.

So yeah, I guess go with ~2.1.0 and not ^2.1. When PHPStan 2.2 comes out, we'll have to manually upgrade.

"yoast/phpunit-polyfills": "^1.1.0"
},
"config": {
Expand All @@ -32,6 +33,7 @@
"lock": false
},
"scripts": {
"analyse": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😛

Suggested change
"analyse": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",
"analyze": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I wonder, why not just:

Suggested change
"analyse": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",
"phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be closer to test:php:stan.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm torn. I prefer the US spelling, but the name of the PHPStan command is analyse and the muscle memory of folks who use PHPStan. 2 years ago this would have been a nit, but it also affects LLMs since the inconsistency can get in the way of their inference

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about just phpstan then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a compat script which isn't a verb already.

"compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source",
"format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source",
"lint": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --report=summary,source",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"env:logs": "node ./tools/local-env/scripts/docker.js logs",
"env:pull": "node ./tools/local-env/scripts/docker.js pull",
"test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.js",
"test:php:stan": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpstan analyse --memory-limit=2G",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about this one. There isn't an npm script for PHPCS. Is that an oversight? It's in a grunt task instead: npx grunt lint:php. It seems like there should be something exposed for PHPCS if something is added for PHPStan.

That said, I'm not sure about test:php:stan. It's not actually running tests. It's analyzing. And "stan" isn't a thing, as I understand it is "STatic+ANalysis". I'd feel better about something like static-analysis:phpstan.

Relatedly: I want to add TypeScript tsc here as well, so that could be static-analysis:js which just runs tsc.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm good with whatever here 🤷.

I prefixed it with test because that's matches the files and to be pedantic we're testing type safety, also linting is also a type of "static analysis". FWIW downstream I usually do npm run lint:php:stan (or even npm run lint:php:types to match a lint:js:types ), which sits nicely next to npm run lint:php and npm run lint:js, and works well with autocomplete and progressive disclosure.

And "stan" isn't a thing,

Agreed, the motivation here is just that {lint|test}:php:phpstan is redundantly ugly.

"test:php": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpunit",
"test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt",
"test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js",
Expand Down
3 changes: 3 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@
<exclude-pattern>/tests/phpunit/build*</exclude-pattern>
<exclude-pattern>/tests/phpunit/data/*</exclude-pattern>

<!-- PHPStan bootstrap, stubs, and baseline. -->
<exclude-pattern>/tests/phpstan/*</exclude-pattern>

<exclude-pattern>/tools/*</exclude-pattern>

<!-- Drop-in plugins. -->
Expand Down
36 changes: 36 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# PHPStan configuration for WordPress Core.
#
# To overload this configuration, copy this file to phpstan.neon and adjust as needed.
#
# https://phpstan.org/config-reference

includes:
# The base configuration file for using PHPStan with the WordPress core codebase.
- tests/phpstan/base.neon

# The baseline file includes preexisting errors in the codebase that should be ignored.
# https://phpstan.org/user-guide/baseline
- tests/phpstan/baseline.php

parameters:
# https://phpstan.org/user-guide/rule-levels
level: 0
reportUnmatchedIgnoredErrors: true

ignoreErrors:
# Level 0:
- # Inner functions aren't supported by PHPStan.
message: '#Function wxr_[a-z_]+ not found#'
path: src/wp-admin/includes/export.php
-
identifier: function.inner
path: src/wp-admin/includes/export.php
count: 13
-
identifier: function.inner
path: src/wp-admin/includes/file.php
count: 1
-
identifier: function.inner
path: src/wp-includes/canonical.php
count: 1
1 change: 1 addition & 0 deletions src/wp-admin/includes/class-wp-filesystem-ssh2.php
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ public function size( $file ) {
* Default 0.
*/
public function touch( $file, $time = 0, $atime = 0 ) {
// @phpstan-ignore-next-line
// Not implemented.
}

Expand Down
4 changes: 2 additions & 2 deletions src/wp-admin/press-this.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ function wp_load_press_this() {
403
);
} elseif ( is_plugin_active( $plugin_file ) ) {
include WP_PLUGIN_DIR . '/press-this/class-wp-press-this-plugin.php';
$wp_press_this = new WP_Press_This_Plugin();
include WP_PLUGIN_DIR . '/press-this/class-wp-press-this-plugin.php'; // @phpstan-ignore include.fileNotFound
$wp_press_this = new WP_Press_This_Plugin(); // @phpstan-ignore class.notFound
$wp_press_this->html();
} elseif ( current_user_can( 'activate_plugins' ) ) {
if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/class-wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ private function get_highest_fetchpriority_with_dependents( string $handle, arra
}
}
}
$stored_results[ $handle ] = $priorities[ $highest_priority_index ]; // @phpstan-ignore parameterByRef.type (We know the index is valid and that this will be a string.)
$stored_results[ $handle ] = $priorities[ $highest_priority_index ];
return $priorities[ $highest_priority_index ];
}

Expand Down
4 changes: 3 additions & 1 deletion src/wp-includes/class-wp-theme-json.php
Original file line number Diff line number Diff line change
Expand Up @@ -3481,7 +3481,7 @@ public function get_svg_filters( $origins ) {
* @param array $theme_json The theme.json like structure to inspect.
* @param array $path Path to inspect.
* @param bool|array $override Data to compute whether to override the preset.
* @return bool
* @return bool|null True if the preset should override the defaults, false if not. Null if the override parameter is invalid.
*/
protected static function should_override_preset( $theme_json, $path, $override ) {
_deprecated_function( __METHOD__, '6.0.0', 'get_metadata_boolean' );
Expand Down Expand Up @@ -3516,6 +3516,8 @@ protected static function should_override_preset( $theme_json, $path, $override

return true;
}

return null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class WP_Customize_Background_Image_Setting extends WP_Customize_Setting {
* @since 3.4.0
*
* @param mixed $value The value to update. Not used.
* @return bool|void Nothing is returned.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @return bool|void Nothing is returned.
* @return bool Always returns true.

*/
public function update( $value ) {
remove_theme_mod( 'background_image_thumb' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
remove_theme_mod( 'background_image_thumb' );
remove_theme_mod( 'background_image_thumb' );
return true;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though I agree this is the real fix, I'm nervous about making any src code changes in a tooling PR. Am I overthinking?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can commit these fixes separately if you like.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class WP_Customize_Filter_Setting extends WP_Customize_Setting {
* @since 3.4.0
*
* @param mixed $value The value to update.
* @return bool|void Nothing is returned.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @return bool|void Nothing is returned.
* @return bool Always returns true.

*/
public function update( $value ) {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function update( $value ) {}
public function update( $value ) {
return true;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class WP_Customize_Header_Image_Setting extends WP_Customize_Setting {
* @global Custom_Image_Header $custom_image_header
*
* @param mixed $value The value to update.
* @return bool|void Nothing is returned.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @return bool|void Nothing is returned.
* @return bool Always returns true.

*/
public function update( $value ) {
global $custom_image_header;
Expand Down
4 changes: 4 additions & 0 deletions src/wp-includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -3765,6 +3765,10 @@ function wp_nonce_ays( $action ) {
* is a WP_Error.
* @type bool $exit Whether to exit the process after completion. Default true.
* }
*
* @return never|void Returns false if `$args['exit']` is false, otherwise exits.
*
* @phpstan-return ($args['exit'] is false ? void : never)
*/
function wp_die( $message = '', $title = '', $args = array() ) {
global $wp_query;
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -4116,7 +4116,7 @@ function get_taxonomies_for_attachments( $output = 'names' ) {
* false otherwise.
*/
function is_gd_image( $image ) {
if ( $image instanceof GdImage
if ( $image instanceof GdImage // @phpstan-ignore class.notFound (Only available with PHP8+.)
|| is_resource( $image ) && 'gd' === get_resource_type( $image )
) {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* Holds, sanitizes, processes, and prints CSS declarations for the style engine.
*
* @since 6.1.0
*
* @phpstan-consistent-constructor
*/
#[AllowDynamicProperties]
class WP_Style_Engine_CSS_Rules_Store {
Expand Down
Loading
Loading