diff --git a/.github/workflows/php-static-analysis.yml b/.github/workflows/php-static-analysis.yml new file mode 100644 index 0000000000000..2c1d4a4c95c0d --- /dev/null +++ b/.github/workflows/php-static-analysis.yml @@ -0,0 +1,97 @@ +name: PHPStan Static Analysis + +on: + # PHPStan testing was introduced in 7.0.0. + 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' + - 'tests/phpstan/baseline.php' + # 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}`, + } + }); diff --git a/.github/workflows/reusable-php-static-analysis.yml b/.github/workflows/reusable-php-static-analysis.yml new file mode 100644 index 0000000000000..6773008ec449d --- /dev/null +++ b/.github/workflows/reusable-php-static-analysis.yml @@ -0,0 +1,121 @@ +## +# 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: Get Gutenberg ref + id: gutenberg-ref + run: echo "ref=$(node -e 'console.log(require("./package.json").gutenberg.ref)')" >> "$GITHUB_OUTPUT" + + - name: Cache Gutenberg + uses: actions/cache@v4 + with: + path: | + gutenberg + .gutenberg-hash + key: gutenberg-${{ steps.gutenberg-ref.outputs.ref }}-${{ hashFiles('tools/gutenberg/*') }} + + - name: Install npm dependencies + run: npm ci --ignore-scripts + + - name: Build WordPress + run: npm run build:dev + + - name: Cache PHP Static Analysis scan cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + 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 diff --git a/.gitignore b/.gitignore index 3997df4c9d603..2705dadd54b2b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ wp-tests-config.php /gutenberg /tests/phpunit/build /wp-cli.local.yml +/phpstan.neon /jsdoc /composer.lock /vendor diff --git a/composer.json b/composer.json index 2c5b20f7879a9..2c5923e9e3257 100644 --- a/composer.json +++ b/composer.json @@ -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", "yoast/phpunit-polyfills": "^1.1.0" }, "config": { @@ -32,6 +33,7 @@ "lock": false }, "scripts": { + "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", "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", diff --git a/package.json b/package.json index 766e241ff8d6d..fcd29cb1b49ad 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "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", "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", + "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan", "gutenberg:checkout": "node tools/gutenberg/checkout-gutenberg.js", "gutenberg:build": "node tools/gutenberg/build-gutenberg.js", "gutenberg:copy": "node tools/gutenberg/copy-gutenberg-build.js", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index a8387b3604c9b..efb679fb6c13b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -81,6 +81,9 @@ /tests/phpunit/build* /tests/phpunit/data/* + + /tests/phpstan/* + /tools/* diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000000000..e74e6ec1a441b --- /dev/null +++ b/phpstan.neon.dist @@ -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 diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 9e0cb885b0bcc..30bd38c3cf2f2 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -672,6 +672,7 @@ public function size( $file ) { * Default 0. */ public function touch( $file, $time = 0, $atime = 0 ) { + // @phpstan-ignore-next-line // Not implemented. } diff --git a/src/wp-admin/press-this.php b/src/wp-admin/press-this.php index c91df1c96b84b..45021964364a3 100644 --- a/src/wp-admin/press-this.php +++ b/src/wp-admin/press-this.php @@ -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 ) ) { diff --git a/src/wp-content/themes/twentyfourteen/inc/featured-content.php b/src/wp-content/themes/twentyfourteen/inc/featured-content.php index b98ab983919df..3193aa8b93549 100644 --- a/src/wp-content/themes/twentyfourteen/inc/featured-content.php +++ b/src/wp-content/themes/twentyfourteen/inc/featured-content.php @@ -212,7 +212,7 @@ public static function delete_transient() { * @since Twenty Fourteen 1.0 * * @param WP_Query $query WP_Query object. - * @return WP_Query Possibly-modified WP_Query. + * @return void */ public static function pre_get_posts( $query ) { diff --git a/src/wp-content/themes/twentytwenty/inc/template-tags.php b/src/wp-content/themes/twentytwenty/inc/template-tags.php index fdf51ccee9624..e15ae6652bbec 100644 --- a/src/wp-content/themes/twentytwenty/inc/template-tags.php +++ b/src/wp-content/themes/twentytwenty/inc/template-tags.php @@ -29,7 +29,7 @@ * * @param array $args Arguments for displaying the site logo either as an image or text. * @param bool $display Display or return the HTML. - * @return string Compiled HTML based on our arguments. + * @return string|void Compiled HTML based on our arguments. */ function twentytwenty_site_logo( $args = array(), $display = true ) { $logo = get_custom_logo(); @@ -107,7 +107,7 @@ function twentytwenty_site_logo( $args = array(), $display = true ) { * @since Twenty Twenty 1.0 * * @param bool $display Display or return the HTML. - * @return string The HTML to display. + * @return string|void The HTML to display. */ function twentytwenty_site_description( $display = true ) { $description = get_bloginfo( 'description' ); @@ -249,7 +249,7 @@ function twentytwenty_edit_post_link( $link, $post_id, $text ) { * * @param int $post_id The ID of the post. * @param string $location The location where the meta is shown. - * @return string Post meta HTML. + * @return string|void Post meta HTML. */ function twentytwenty_get_post_meta( $post_id = null, $location = 'single-top' ) { diff --git a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php index 53db8dffb216f..d4bc16e20171d 100644 --- a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php +++ b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php @@ -35,8 +35,12 @@ public function __construct() { public function register( $wp_customize ) { // Change site-title & description to postMessage. - $wp_customize->get_setting( 'blogname' )->transport = 'postMessage'; // @phpstan-ignore-line. Assume that this setting exists. - $wp_customize->get_setting( 'blogdescription' )->transport = 'postMessage'; // @phpstan-ignore-line. Assume that this setting exists. + foreach ( array( 'blogname', 'blogdescription' ) as $setting_id ) { + $setting = $wp_customize->get_setting( $setting_id ); + if ( $setting ) { + $setting->transport = 'postMessage'; + } + } // Add partial for blogname. $wp_customize->selective_refresh->add_partial( diff --git a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php index d5672c4054f3c..6644e7d02eeab 100644 --- a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php +++ b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php @@ -98,7 +98,7 @@ public function enqueue_scripts() { if ( is_rtl() ) { $url = get_template_directory_uri() . '/assets/css/style-dark-mode-rtl.css'; } - wp_enqueue_style( 'tt1-dark-mode', $url, array( 'twenty-twenty-one-style' ), wp_get_theme()->get( 'Version' ) ); // @phpstan-ignore-line. Version is always a string. + wp_enqueue_style( 'tt1-dark-mode', $url, array( 'twenty-twenty-one-style' ), wp_get_theme()->get( 'Version' ) ); } /** diff --git a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php index 398ef82bbd7c8..e32d050b4e455 100644 --- a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php +++ b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php @@ -189,10 +189,9 @@ public static function get_svg( $group, $icon, $size ) { if ( array_key_exists( $icon, $arr ) ) { $repl = sprintf( '