#64898 closed task (blessed) (fixed)
PHPStan code quality improvements for 7.1
| Reported by: | desrosj | Owned by: | |
|---|---|---|---|
| Priority: | normal | Milestone: | 7.1 |
| Component: | General | Version: | |
| Severity: | normal | Keywords: | has-patch has-unit-tests |
| Cc: | Focuses: | coding-standards |
Change History (99)
This ticket was mentioned in PR #11302 on WordPress/wordpress-develop by @Soean.
5 months ago
#1
- Keywords has-patch added
This ticket was mentioned in PR #11303 on WordPress/wordpress-develop by @Soean.
5 months ago
#2
The url value is $tag->link unless '#' === $tag->link, then its #. Which is the same as $tag->link.
So we can simplify it and always use $tag->link.
Trac ticket: https://core.trac.wordpress.org/ticket/64898
@SergeyBiryukov commented on PR #11303:
5 months ago
#4
Thanks for the PR! Merged in r62066.
@SergeyBiryukov commented on PR #11302:
5 months ago
#5
Thanks for the PR! Merged in r62086.
This ticket was mentioned in PR #11340 on WordPress/wordpress-develop by @Soean.
5 months ago
#6
This pull request makes minor code cleanups across several files, removing unnecessary uses of the sprintf function when localizing or outputting static strings. These changes simplify the code and improve readability without affecting functionality.
Introduced in:
- https://core.trac.wordpress.org/changeset/21804/trunk/wp-includes/class-wp-xmlrpc-server.php
- https://core.trac.wordpress.org/changeset/40330/trunk/src/wp-includes/class-wp-customize-widgets.php
- https://core.trac.wordpress.org/changeset/41774/trunk/src/wp-admin/plugin-editor.php
Trac ticket: https://core.trac.wordpress.org/ticket/64898
@SergeyBiryukov commented on PR #11340:
5 months ago
#8
Thanks for the PR! Merged in r62167.
This ticket was mentioned in PR #11331 on WordPress/wordpress-develop by @Soean.
5 months ago
#9
This pull request makes minor code simplifications by removing unnecessary ternary operations and directly assigning boolean expressions. These changes make the code easier to read and maintain, but do not alter the underlying logic.
Trac ticket: https://core.trac.wordpress.org/ticket/64898
@SergeyBiryukov commented on PR #11331:
5 months ago
#11
Thanks for the PR! Merged in r62173.
This ticket was mentioned in PR #11429 on WordPress/wordpress-develop by @Soean.
5 months ago
#12
The unused variable $hooked_blocks in WP_Block_Patterns_Registry->get_all_registered() has been removed.
Introduced in: https://core.trac.wordpress.org/changeset/56805/trunk/src/wp-includes/class-wp-block-patterns-registry.php
Unused since: https://core.trac.wordpress.org/changeset/59101/trunk/src/wp-includes/class-wp-block-patterns-registry.php
Trac ticket: https://core.trac.wordpress.org/ticket/64898
@Soean commented on PR #11429:
5 months ago
#13
Maybe we can use the PHPStan ticket: https://core.trac.wordpress.org/ticket/64898
@westonruter commented on PR #11429:
5 months ago
#15
This was committed by @SergeyBiryukov in r62201 (8510818).
This ticket was mentioned in PR #11692 on WordPress/wordpress-develop by @westonruter.
4 months ago
#16
- Keywords has-unit-tests added
tl;dr: Adds a PHPStan parser-node visitor that bridges WordPress core's @global Type $varname PHPDoc convention to PHPStan's variable type resolution, eliminating ~3,800 false-positive mixed errors without touching any production code.
I use a local PHPStan configuration at level 10. This causes PHPStan to complain a _lot_, including about the use of globals like $wpdb. For code like this:
I get two errors from PHPStan:
- phpstan: Cannot access property $site on mixed.
- phpstan: Part $wpdb->site (mixed) of encapsed string cannot be cast to string.
This is in spite of the fact that the method has:
and
The issue is that PHPStan doesn't recognize this use of @global. It's a WordPress-specific thing. It does recognize this, however:
/** @var wpdb $wpdb */ global $wpdb;
While we could fix the issues by going throughout core and adding these @var tags, this would be extremely noisy and be of very little value. Instead, we can introduce a PHPStan extension for core which causes PHPStan to treat the @global annotations as aliases for inline @var annotations. This is what is implemented by this PR.
# Approach by Claude
Adds a custom parser node visitor, WordPress\PHPStan\GlobalDocBlockVisitor, that:
- Walks each
FunctionLikenode and parses any@global Type $nametags from its docblock. - For every
global $name;statement inside that function body, if$namematches a documented tag, attaches a synthetic/** @var Type $name */doc comment to theglobalAST node. - PHPStan's existing
@var-on-global handling then assigns the documented type.
Behavior:
- Functions without
@globaltags are untouched; their globals continue to resolve asmixed. - Globals not listed in the function's
@globalblock are untouched; they continue to resolve asmixed. - Hand-written
@varannotations already present on aglobalstatement are respected and preserved. - Nested functions/closures get their own
@globalmap (visitor uses a stack).
The visitor is registered as a phpstan.parser.richParserNodeVisitor service in tests/phpstan/base.neon and autoloaded via a new autoload-dev PSR-4 entry in composer.json mapping WordPress\PHPStan\ to tests/phpstan/. composer install (which runs composer dump-autoload) makes the class available to PHPStan automatically.
# Result
When running composer -- phpstan --configuration=phpstan.neon.dist --level=10:
- Before:
[ERROR] Found 40069 errors - After:
[ERROR] Found 36300 errors
## Claude comparison
### Comparison summary
| Trunk | Branch | Diff | |
| ------------------------------------------- | ----: | -----: | --------: |
| Total errors (PHPStan totals) | 40069 | 36300 | -3769 |
| Unique errors (file+line+id+msg) | 39372 | 35705 | -3667 |
| Fixed by branch (in trunk, not branch) | — | — | 4456 |
| Introduced by branch (in branch, not trunk) | — | — | 789 |
The fixed-vs-introduced asymmetry exists because the visitor narrows $wpdb etc. from mixed to wpdb, which both removes errors (the mixed.method() family) and exposes new ones (real type mismatches in wpdb method signatures, @var array injections that PHPStan wants array<...> for, etc.).
### Top fixed identifiers
1375 method.nonObject -- $foo->method() on what was mixed 1040 property.nonObject -- $foo->prop on what was mixed 607 encapsedStringPart.nonString -- "FROM $wpdb->posts" interpolations 462 argument.type 304 offsetAccess.nonOffsetAccessible 188 binaryOp.invalid 126 foreach.nonIterable 119 return.type 64 cast.int 46 assignOp.invalid 39 property.notFound 32 method.notFound 21 offsetAccess.invalidOffset 15 assign.propertyType 6 preInc.type
### Top fixed messages
304 Cannot call method prepare() on mixed. 233 Cannot access property $posts on mixed. 188 Part $wpdb->posts (mixed) of encapsed string cannot be cast to string. 126 Argument of an invalid type mixed supplied for foreach, only iterables are supported. 111 Cannot access offset string on mixed. 110 Cannot call method query() on mixed. 108 Cannot call method get_results() on mixed. 100 Cannot call method get_var() on mixed. 82 Cannot access offset mixed on mixed. 75 Cannot access property $comments on mixed. 64 Cannot cast mixed to int. 64 Cannot call method get_col() on mixed. 59 Cannot call method update() on mixed. 53 Cannot access property $options on mixed. 52 Part $wpdb->comments (mixed) of encapsed string cannot be cast to string. 51 Cannot call method delete() on mixed. 44 Cannot access property $term_taxonomy on mixed. 41 Cannot call method esc_like() on mixed. 38 Cannot access property $postmeta on mixed. 38 Part $wpdb->options (mixed) of encapsed string cannot be cast to string. 27 Cannot access property $site on mixed. ← your original case
The 4456 fixed errors all stem from globals (mostly $wpdb, $wp_query, $wp_locale, $wp_filter, etc.) becoming concretely typed where @global tags exist. Nothing was suppressed via baseline; these are real PHPStan errors that the visitor now resolves.
Trac ticket: https://core.trac.wordpress.org/ticket/64898
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 4.7
Used for: Research and code writing
@westonruter commented on PR #11692:
4 months ago
#17
cc @justlevine, @apermo, @SergeyBiryukov
@westonruter commented on PR #11692:
4 months ago
#18
cc @szepeviktor
@westonruter commented on PR #11692:
4 months ago
#19
With 9fc7599, the error count is now brought down by another _three_ to 36,297 (from 36,300).
@szepe.viktor commented on PR #11692:
4 months ago
#20
@westonruter Please do not kill my package.
@westonruter commented on PR #11692:
4 months ago
#21
@szepeviktor Not my intention! 😅
If anything, this new PHPStan extension would reduce the need for php-stubs/wordpress-stubs. But it's just here for core. It seems like adding such @global support would be useful in your extension for themes and plugins to leverage?
@westonruter commented on PR #11692:
4 months ago
#22
With 38f2bf2, the total error count is brought down by another dozen to 36,285 (from 36,297). The regex was overengineered.
@szepe.viktor commented on PR #11692:
4 months ago
#23
It seems like adding such
@globalsupport would be useful in your extension for themes and plugins to leverage?
No. Some day WordPress will use PHP standards.
@westonruter commented on PR #11692:
4 months ago
#24
No. Some day WordPress will use PHP standards.
But wouldn't that kill your package? 😏
@westonruter commented on PR #11692:
4 months ago
#26
Committed in r62292 (9e0b63d).
This ticket was mentioned in PR #11979 on WordPress/wordpress-develop by @huzaifaalmesbah.
3 months ago
#28
Resolves PHPStan level 1 variable.undefined errors in four user/plugin admin files (98 of 564 project-wide level-1 errors). The project's enforced level (0) continues to pass.
- Declare
@globaldocblocks +globalstatements for core globals ($wpdb,$current_user,$wp_roles,$_wp_admin_css_colors,$status,$page,$plugins,$user_ID,$usersearch,$blog_id). - Initialize
$redirectinwp-admin/users.phpfor theempty( $_REQUEST )branch. - Initialize
$user_id,$old_user_dataat the top ofwp_insert_user()and$manage_urlin_wp_privacy_send_request_confirmation_notification()so they're always defined. - Bug fix:
wp-admin/user-edit.php:166referenced an undefined$user_loginin the multisite signup-email update query; corrected to$user->user_login.
## Files changed
| File | Errors before (level 1) | After |
|---|---|---|
src/wp-admin/plugins.php | 40 | 0 |
src/wp-admin/users.php | 25 | 0 |
src/wp-admin/user-edit.php | 15 | 0 |
src/wp-includes/user.php | 18 | 0 |
## Verification
vendor/bin/phpstan analyse --level=1 --memory-limit=2G \ --configuration=phpstan.neon.dist \ src/wp-admin/plugins.php \ src/wp-admin/users.php \ src/wp-admin/user-edit.php \ src/wp-includes/user.php
### Expected: [OK] No errors
@noruzzaman commented on PR #11979:
3 months ago
#30
Nice work! I noticed that src/wp-admin/user-new.php has similar undefined variable issues under PHPStan level 1 (e.g., $blog_id on line 68 and $wpdb on line 243).
Would it be possible to include it in this PR as well?
This ticket was mentioned in PR #12158 on WordPress/wordpress-develop by @westonruter.
2 months ago
#32
This ticket was mentioned in PR #12405 on WordPress/wordpress-develop by @westonruter.
7 weeks ago
#36
This ticket was mentioned in PR #12407 on WordPress/wordpress-develop by @westonruter.
6 weeks ago
#39
The GlobalDocBlockVisitor PHPStan extension (introduced in 9e0b63d872) only read @global tags from the enclosing function's docblock, and skipped global statements outside any function entirely. As a result, file-scope global statements (e.g. in admin templates like wp-admin/edit-form-comment.php that are included into another scope) required a redundant @var tag in addition to @global for PHPStan to resolve the variable's type:
/** * @global WP_Comment $comment Global comment object. * @var WP_Comment $comment */ global $comment;
With this change, @global tags in a docblock attached directly to a global statement are honored as well, so the @var tag above is no longer needed. Statement-level tags take precedence over the enclosing function's tags for the same variable, and handwritten @var annotations continue to win over synthetic ones.
Verified by running PHPStan against src/wp-admin/edit-form-comment.php with the redundant @var removed: all Cannot access property ... on mixed errors for $comment are resolved by the @global tag alone.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5
Used for: Diagnosing the visitor's file-scope gap and implementing the fix; reviewed, tested, and verified by me.
---
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.
🤖 Generated with Claude Code
This ticket was mentioned in PR #12408 on WordPress/wordpress-develop by @westonruter.
6 weeks ago
#40
Follow-up to [62437] which corrected the type of WP_User_Request::$user_id and added numeric-string as a richer PHPStan type to WP_Post::$post_author and WP_Post::$comment_count. This PR extends the same treatment to the remaining DB-backed class properties which are documented as string but always hold numeric strings, and corrects the documented types of a few magic properties along the way:
WP_Comment: Adds@phpstan-var numeric-stringto$comment_ID,$comment_post_ID,$comment_karma,$comment_parent, and$user_id. ($comment_approvedis intentionally excluded since it can also bespam,trash, orpost-trashed.)WP_Site: Adds@phpstan-var numeric-stringto$blog_id,$site_id,$public,$archived,$mature,$spam,$deleted, and$lang_id.WP_Network: Adds@phpstan-var numeric-stringto the private$blog_idproperty, and documents the corresponding magic$blog_idproperty (exposed via__get(), which returns(string) $this->get_main_site_id()) with an@propertytag and an@phpstan-property numeric-stringrefinement, as it was previously missing from the class-level tags.WP_Site::$post_count(magic): Corrects the type frominttoint|string|false(int|numeric-string|falsefor PHPStan). The value is lazy-loaded viaget_option( 'post_count' )inWP_Site::get_details(), so despiteupdate_posts_count()storing an integer, it is a numeric string once read back from the database, andfalsewhen the option is not set (new sites with no published posts — seeTests_Multisite_Site_Details::test_site_details_cached_including_false_values()). The integer can also be returned within the same request via the options cache and can persist in thesite-detailsobject cache.WP_User::$user_status(magic): Adds an@phpstan-property numeric-stringrefinement. The value comes raw off thewp_usersrow, where the column always exists.WP_User::$user_level(magic): Corrects the type frominttoint|string(int|numeric-string|''for PHPStan). The value resolves throughget_user_meta( ..., 'wp_user_level', true ), which returns a numeric string, or an empty string when the metadata is absent (e.g. a multisite user with no role on the site). It is an integer only afterWP_User::update_user_level_from_caps()has assigned one to the instance in the same request, such as viaWP_User::set_role()duringwp_insert_user(). ($spam/$deletedare left as plainstringsince on single site they fall through to user meta and return''.)
WP_Post and WP_Term integer-like fields are not touched because they are actually cast to int during hydration (WP_Post::get_instance()/sanitize_term()).
Existing core call sites are unaffected: nothing in core reads the magic WP_Site::$post_count, and the WP_User::$user_level readers either cast to (int) (wp_set_current_user()) or use loose comparisons in deprecated.php.
Trac tickets: https://core.trac.wordpress.org/ticket/64898, https://core.trac.wordpress.org/ticket/64896
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5
Used for: Auditing core for remaining DB-backed properties missing numeric-string types, tracing the runtime types of the magic properties, authoring the docblock changes and commit messages, and verifying with PHPCS/PHPStan. All changes were reviewed and directed by me.
---
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.
🤖 Generated with Claude Code
@westonruter commented on PR #12407:
6 weeks ago
#42
[!NOTE]
This analysis and comment were authored by AI — Claude Code using the Claude Fable 5 model (claude-fable-5), at @westonruter's direction, per the AI Guidelines.
## PHPStan before/after analysis
Methodology: Full-codebase PHPStan runs on trunk (before) and this branch (after), using the repo's phpstan.neon.dist config overloaded to level 10 with bleedingEdge.neon (the dist config's level 0 barely exercises type resolution, so it would understate the impact of this change). The result cache was fully cleared before each run, since changing a parser-extension class does not reliably invalidate PHPStan's result cache. Errors are matched as a per-occurrence multiset keyed by file + identifier + message (line numbers excluded), so fixed − introduced = net always reconciles.
### Summary
| Metric | Value |
|---|---|
Errors in trunk | 34,730 |
Errors in fix/phpstan-global-docblock-file-scope | 34,430 |
| Net change | -300 |
| Errors fixed | 354 |
| Errors introduced | 54 |
| % of baseline fixed | 1.02% |
| Net reduction | 0.86% |
<sub>Per-occurrence multiset diff: 354 fixed − 54 introduced = 300 net. ✓</sub>
### Errors fixed by identifier (354)
| Count | Identifier |
| ---: | --- |
| 148 | argument.type
|
| 105 | property.nonObject
|
| 30 | offsetAccess.nonOffsetAccessible
|
| 29 | method.nonObject
|
| 18 | encapsedStringPart.nonString
|
| 16 | binaryOp.invalid
|
| 6 | foreach.nonIterable
|
| 1 | assign.propertyType
|
| 1 | echo.nonString
|
<details><summary>Most-improved files</summary>
| Count | File |
| ---: | --- |
| 68 | src/wp-admin/edit-form-advanced.php
|
| 46 | src/wp-admin/edit-form-blocks.php
|
| 40 | src/wp-admin/edit-form-comment.php
|
| 32 | src/wp-admin/includes/menu.php
|
| 25 | src/wp-admin/admin-header.php
|
| 24 | src/wp-admin/customize.php
|
| 23 | src/wp-admin/install.php
|
| 20 | src/wp-admin/post.php
|
| 18 | src/wp-admin/upgrade.php
|
| 13 | src/wp-admin/edit.php
|
| 11 | src/wp-admin/edit-comments.php
|
| 8 | src/wp-admin/admin.php
|
| 6 | src/wp-admin/edit-tags.php
|
| 5 | src/wp-content/themes/twentytwenty/index.php
|
| 2 | src/wp-admin/admin-footer.php
|
| 2 | src/wp-admin/menu-header.php
|
| 2 | src/wp-admin/options-general.php
|
| 1 | src/wp-admin/includes/export.php
|
| 1 | src/wp-admin/includes/schema.php
|
| 1 | src/wp-admin/includes/template.php
|
| 1 | src/wp-admin/link-parse-opml.php
|
| 1 | src/wp-content/themes/twentyeleven/content-featured.php
|
| 1 | src/wp-content/themes/twentyeleven/header.php
|
| 1 | src/wp-content/themes/twentyseventeen/template-parts/page/content-front-page-panels.php
|
| 1 | src/wp-content/themes/twentyten/header.php
|
</details>
### Errors introduced by identifier (54)
| Count | Identifier |
| ---: | --- |
| 14 | offsetAccess.invalidOffset
|
| 12 | argument.type
|
| 10 | variable.undefined
|
| 8 | missingType.iterableValue
|
| 6 | property.nonObject
|
| 3 | offsetAccess.nonOffsetAccessible
|
| 1 | method.nonObject
|
<details><summary>Introduced errors (detail)</summary>
src/wp-admin/admin-header.php— [argument.type] Parameter #1 $text of function esc_js expects string, string|null given.src/wp-admin/admin.php— [missingType.iterableValue] PHPDoc tag @var for variable $wp_importers has no value type specified in iterable type array.src/wp-admin/admin.php— [offsetAccess.invalidOffset] (×3) Possibly invalid array key type mixed.src/wp-admin/admin.php— [variable.undefined] (×3) Variable $pagenow might not be defined.src/wp-admin/customize.php— [argument.type] Parameter #1 $required of function is_wp_version_compatible expects string, string|false given.src/wp-admin/customize.php— [argument.type] Parameter #1 $required of function is_php_version_compatible expects string, string|false given.src/wp-admin/edit-comments.php— [argument.type] Parameter #1 $text of function esc_attr expects string, int<min, -1>|int<1, max> given.src/wp-admin/edit-form-advanced.php— [offsetAccess.nonOffsetAccessible] (×2) Cannot access offset string on mixed.src/wp-admin/edit-form-advanced.php— [argument.type] Parameter #1 $text of function esc_attr expects string, int given.src/wp-admin/edit-form-blocks.php— [missingType.iterableValue] PHPDoc tag @var for variable $wp_meta_boxes has no value type specified in iterable type array.src/wp-admin/edit-form-comment.php— [argument.type] Parameter #1 $post of function get_edit_post_link expects int|WP_Post, string given.src/wp-admin/edit-form-comment.php— [argument.type] (×2) Parameter #1 $post of function get_the_title expects int|WP_Post, string given.src/wp-admin/edit.php— [offsetAccess.nonOffsetAccessible] Cannot access offset non-falsy-string on mixed.src/wp-admin/includes/menu.php— [offsetAccess.invalidOffset] (×11) Possibly invalid array key type mixed.src/wp-admin/includes/menu.php— [missingType.iterableValue] PHPDoc tag @var for variable $compat has no value type specified in iterable type array.src/wp-admin/includes/menu.php— [missingType.iterableValue] PHPDoc tag @var for variable $menu has no value type specified in iterable type array.src/wp-admin/includes/menu.php— [missingType.iterableValue] PHPDoc tag @var for variable $submenu has no value type specified in iterable type array.src/wp-admin/includes/menu.php— [argument.type] Parameter #2 $callback of function usort expects callable(mixed, mixed): int, 'sort_menu' given.src/wp-admin/includes/schema.php— [missingType.iterableValue] PHPDoc tag @var for variable $wp_queries has no value type specified in iterable type array.src/wp-admin/install.php— [argument.type] Parameter #1 $version1 of function version_compare expects string, string|null given.src/wp-admin/install.php— [method.nonObject] Cannot call method get_error_message() on string|WP_Error.src/wp-admin/menu-header.php— [missingType.iterableValue] PHPDoc tag @var for variable $menu has no value type specified in iterable type array.src/wp-admin/menu-header.php— [missingType.iterableValue] PHPDoc tag @var for variable $submenu has no value type specified in iterable type array.src/wp-admin/post.php— [property.nonObject] (×3) Cannot access property $post_type on array|WP_Post.src/wp-admin/post.php— [property.nonObject] Cannot access property $post_status on array|WP_Post.src/wp-admin/post.php— [argument.type] Parameter #1 $post of function use_block_editor_for_post expects int|WP_Post, array|WP_Post given.src/wp-admin/post.php— [property.nonObject] (×2) Cannot access property $ID on array|WP_Post.src/wp-admin/upgrade.php— [argument.type] Parameter #1 $version1 of function version_compare expects string, string|null given.src/wp-includes/ms-settings.php— [variable.undefined] (×7) Variable $current_blog might not be defined.
</details>
### Interpretation of the 54 "introduced" errors
None are regressions caused by the visitor change. They fall into three buckets:
- Sharper re-statements of pre-existing errors (~22): the same call site now reports a narrower type than
mixed, so the diff counts one fixed + one introduced (e.g.post.php: "Cannot access property$post_typeonarray|WP_Post", previously "onmixed"). - Imprecise
@globaldocs now actually consumed (8 ×missingType.iterableValue): tags like@global array $menunow feed the analysis, so PHPStan asks for a value type. Fixable by tightening those docblocks. - Latent issues the sharper types exposed (~24): genuinely new findings, e.g.
variable.undefinedfor$pagenow(×3 inadmin.php) and$current_blog(×7 inms-settings.php), plus 14 ×offsetAccess.invalidOffset(11 inwp-admin/includes/menu.php) — previously invisible because the variables weremixed.
Net: this change resolves 354 errors and surfaces ~30 previously-invisible latent issues, for a net reduction of 300 errors (−0.86%) at level 10.
🤖 Generated with Claude Code
This ticket was mentioned in PR #12426 on WordPress/wordpress-develop by @westonruter.
6 weeks ago
#45
Adds precise, conditional PHPStan return types to the core post retrieval, sanitization, and insertion APIs, along with a small amount of behavior-preserving runtime hardening (and one genuine latent-bug fix) that these types surfaced.
## Conditional return types
Functions whose return type depends on an argument now carry a @phpstan-return keyed on that argument, so static analysis can narrow the result at each call site:
get_post(),get_page(),get_page_by_path()— keyed on$output:array<string, mixed>|nullforARRAY_A,array<int, mixed>|nullforARRAY_N,WP_Post|nullotherwise.get_children()— keyed first on$args['fields']('ids'→int[], mirroringget_posts()) and then on$output.get_posts()andWP_Query::query()—$argswithfields => 'ids'→int[], otherwiseWP_Post[].wp_get_recent_posts()—ARRAY_A→array<int, array<string, mixed>>, otherwiseWP_Post[]|false.get_post_field()/sanitize_post_field()— keyed on$field:intforID/post_parent/menu_order,non-negative-int[]forancestors,stringotherwise (get_post_field()additionally includes the''failure return).sanitize_post()— same type in as out (WP_Post,stdClass, orarray).get_post_types()—'names'→string[], otherwiseWP_Post_Type[].wp_insert_post(),wp_update_post(),wp_insert_attachment()—$wp_error === false→int, otherwiseint|WP_Error.
Matching @phpstan-param tags were added where the analyzed return depends on a narrowed input (e.g. $output, $context, $filter).
## Runtime changes
Most of the diff is documentation-only, but a few small code changes were needed for the types to hold:
get_post()now null-guards theWP_Post::filter( 'raw' )result.filter( 'raw' )callsWP_Post::get_instance(), which can returnfalsefor a since-deleted post; the previous code would then call->to_array()onfalse. This is a real latent fix, not just a typing change.WP_Post::filter()is retypedWP_Post|falseaccordingly and its missing summary docblock is filled in.WP_Post::get_instance()cache-hit guard usesinstanceof stdClass/instanceof WP_Post(behavior-preserving vs. the old truthiness check) and casts$_post->IDtointbefore caching.get_post()routes non-numeric scalar$postvalues straight tonullinstead of issuing a pointlessWHERE ID = 0query.sanitize_post_field()casts$valuetoarraybeforearray_map( 'absint', ... )for theancestorsfield.WP_Post::get_category()/get_tags()guard against aWP_Errorfromget_the_terms().- Assorted
(int)casts on post IDs passed toget_post()/get_instance().
Verified with phpstan-diff against trunk: no new errors on changed lines.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Drafting the conditional return type annotations and the accompanying runtime hardening under my direction. I reviewed, tested, and take responsibility for all of the changes.
@westonruter commented on PR #12426:
6 weeks ago
#46
## PHPStan impact: trunk → this branch
Ran two full PHPStan passes (local phpstan.neon, level 10) on trunk and on this branch to quantify the effect.
| Metric | Value |
| --- | ---: |
Errors in trunk | 34,431 |
| Errors in this branch | 34,149 |
| Net change | −282 |
| Fixed | 310 |
| Introduced | 28 |
| % of baseline fixed | 0.90% |
| Net reduction | 0.82% |
Reconciliation checks out: 310 − 28 = 282. A 0.82% net reduction from a mostly-annotation diff — the precise return types let PHPStan resolve mixed/object returns at hundreds of downstream call sites. Top beneficiaries: post.php (−64), class-wp-xmlrpc-server.php (−52), media.php (−15), ajax-actions.php/admin/post.php/class-wp-customize-nav-menus.php (−12 each).
### Errors fixed by identifier (310)
| Count | Identifier |
| ---: | --- |
| 110 | property.nonObject
|
| 77 | argument.type
|
| 75 | offsetAccess.nonOffsetAccessible
|
| 13 | encapsedStringPart.nonString
|
| 9 | missingType.iterableValue
|
| 8 | return.type
|
| 7 | assign.propertyType
|
| 4 | offsetAccess.invalidOffset
|
| 3 | property.notFound
|
| 3 | method.nonObject
|
| 1 | binaryOp.invalid
|
### Triage of the 28 "introduced"
None are regressions in the changed code, with one known-trade-off exception. They fall into four buckets:
1. Latent call-site bugs *exposed* by sharper types (~15). Since get_post() / get_page_by_path() now return array|WP_Post|null precisely (instead of mixed/object), PHPStan can finally see call sites that assume a bare WP_Post:
media.php(5) andclass-wp-customize-nav-menus.php(7):Cannot access property $ID/$post_title on array|WP_Post|null,expects WP_Post, array|WP_Post|null given.class-wp-query.php(1): passesmixedtoget_page_by_path()'s now-typed$post_type.
These are pre-existing fragilities (unchecked null, ignored ARRAY_A case) surfaced by the new types — good candidates for follow-up under this same ticket rather than expanding this PR.
2. Dead-check / unused-type warnings that are artifacts of the narrowed types (3):
WP_Post::get_instance()—empty( $_post->filter )flagged because$filteris now the'raw'|'edit'|…enum (never falsy); it's a defensive check.nav-menu.php—isset()on the non-nullableWP_Post::$ID._fix_attachment_links()—return.unusedType, because precise types made aWP_Errorbranch provably unreachable.
3. Genuine imprecision in this branch's annotation (2):
post.php — sanitize_post_field() should return array<int<0, max>>|int|string but returns mixed (×2)
The 'raw' context returns $value unchanged (mixed), but the conditional return declares string for that field class. In practice raw DB values are strings, but PHPStan can't prove it. Leaving this as a deliberate precision-vs-soundness trade-off; the net result is still −282.
4. Nondeterministic churn unrelated to the diff (~7). The WP_Taxonomy (upgrade.php, block-template-utils.php), wp-mail.php, and Twenty Fourteen entries — verified the totals are identical before/after (e.g. WP_Taxonomy-related errors are 260 in both runs), just redistributed between passes. None of that code is touched here.
---
<sub>Analysis performed by Claude (Opus 4.8) via Claude Code, at the request of and reviewed by @westonruter.</sub>
This ticket was mentioned in PR #12455 on WordPress/wordpress-develop by @westonruter.
6 weeks ago
#50
Adds @phpstan-prefixed generics and conditional return types to the six functions in the slashing/unslashing family, so that static analysis knows a string in yields a string out and an array in yields an array out.
No runtime code changes. This PR only touches docblocks. The plain @param/@return tags that the Code Reference parses are left exactly as they were; all new tags are @phpstan- prefixed and sit below them, per the convention already used elsewhere in core.
## What's wrong today
wp_unslash(), wp_slash(), and stripslashes_deep() all promise "in the same type as supplied", but @return string|array cannot express that. Passing a string gets you back array|string, which then poisons every downstream call:
// src/wp-admin/includes/ajax-actions.php:2324 if ( is_array( $_POST['sidebars'] ) ) { // caller narrows correctly foreach ( wp_unslash( $_POST['sidebars'] ) as $key => $val ) {
PHPStan reports Argument of an invalid type array|string supplied for foreach, only iterables are supported. — because wp_unslash() is declared as possibly returning a string, even though the caller just proved the input is an array.
There is also a pre-existing error on trunk inside wp_slash() itself:
src/wp-includes/formatting.php:5817 Parameter #1 $callback of function array_map expects (callable(mixed): mixed)|null, 'wp_slash' given.
wp_slash() recurses via array_map( 'wp_slash', $value ), which hands each element to wp_slash() as mixed. The narrow @param string|array denies that, so PHPStan rejects the function's own recursion. Note the docblock already says @since 5.5.0 Non-string values are left untouched., so accepting mixed is the documented behavior.
## Two soundness bugs this fixes
An earlier iteration of this branch used a bare @phpstan-return T ("returns its input unchanged"). That is not what these functions do, and it made PHPStan believe two false things:
map_deep( array( '1', '2' ), 'intval' ); // inferred: array{'1', '2'} actual: array{1, 2} // map_deep()'s callback is arbitrary and may change the type of every leaf. wp_unslash( "O\'Brien" ); // inferred: 'O\'Brien' actual: 'O'Brien' // String literals must widen — the function rewrites string contents.
Both are fixed by the conditional return types below. stripslashes_from_strings_only() demonstrates why the conditional is required rather than a plain T: with @phpstan-return T, PHPStan correctly rejects the body with should return T but returns string|T of mixed, because T could be a literal-string subtype while stripslashes() returns plain string.
## The changes
| Function | Return type | Rationale |
|---|---|---|
map_deep() | array → array, object → T, else mixed | The callback decides the leaf type. The object branch returns T because map_deep() mutates in place and returns the same instance.
|
stripslashes_from_strings_only() | (T is string ? string : T) | Only strings are altered. |
stripslashes_deep() | string-preserving, array values mapped | Its callback only alters strings, so non-strings pass through as T.
|
wp_slash() | same | Also fixes the array_map error above.
|
wp_unslash() | same | |
add_magic_quotes() | T of array, array values mapped | Lets a precisely-typed superglobal survive wp_magic_quotes().
|
The shared array-value shape is:
array<key-of<T>, ( value-of<T> is string ? string : value-of<T> )>
### On key-of<T> and value-of<T>
key-of<T> is currently a no-op when T is a template type bound to an array — see phpstan/phpstan#14571 (open, unfixed as of 2.2.5). It is retained because it accurately describes the runtime behavior: map_deep() and friends write back to $value[ $index ], so keys are preserved exactly. It will start paying off when that issue is resolved.
value-of<T> resolves today but yields mixed for superglobal data, because PHPStan models $_GET/$_POST as array<mixed>. PHP guarantees their leaves are always string (?input[post][id]=1 produces string("1"), never int), so this too will sharpen if superglobals ever carry precise types.
## Impact on PHPStan error counts
Measured with level: 10 locally (the committed phpstan.neon.dist runs at level: 0, so CI totals are unaffected). Whole-repo counts:
| trunk | this branch | net | |
| --- | ---: | ---: | ---: |
wp_unslash()/wp_slash() parameter checks | 294 | 0 | −294 |
| Everything else | 33,180 | 33,060 | −120 |
| Total | 33,474 | 33,060 | −414 |
The −414 headline is misleading and should not be read as "414 bugs fixed." Breaking it down honestly:
1. Genuinely fixed — the narrowing case from the top of this description. foreach.nonIterable on array|string drops from 11 to 9:
if ( is_array( $_POST['sidebars'] ) ) { foreach ( wp_unslash( $_POST['sidebars'] ) as $key => $val ) { // trunk: foreach.nonIterable
2. Silenced, not fixed (294 errors) — adding @phpstan-param T $value overrides the plain @param string|array, so PHPStan stops checking the argument entirely:
// src/wp-admin/admin.php:142 $plugin_page = wp_unslash( $_GET['page'] ); // trunk: Parameter #1 $value of function wp_unslash expects array|string, mixed given. // branch: (no error)
These 294 are an artifact of PHPStan typing superglobals as array<mixed>; if $_POST['key'] were string|array<…> it would satisfy @param string|array and they would disappear on their own, with or without this PR.
3. Reworded, still present — a naive multiset diff double-counts these as one fixed and one introduced:
// src/wp-activate.php:23 list( $activate_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) ); // trunk: explode expects string, array|string given. // branch: explode expects string, mixed given.
4. Newly reported (53 errors, 29 of them Cannot access offset … on mixed) — these flag call sites that do *not* narrow, and are arguably an improvement:
// src/wp-admin/includes/ajax-actions.php:3323 $attachment = wp_unslash( $_POST['attachment'] ); // no is_array() guard → mixed $id = (int) $attachment['id']; // Cannot access offset 'id' on mixed.
So the defensible summary is: a small number of real fixes, 294 checks deliberately traded away, and 53 new errors that mark unnarrowed call sites — not a −414 improvement.
## Verification
- All six function bodies validate at PHPStan level 10 with no
return.typeerrors. composer lint(PHPCS / WordPress Coding Standards) is clean on both changed files.- Inference spot-checks:
| Expression | trunk | this branch |
|---|---|---|
wp_unslash( $string ) | array\||string | string
|
wp_unslash( $_POST['k'] ) after is_array() | array\||string | array<mixed>
|
wp_slash( array<int, int> ) | array\||string | array<int>
|
wp_slash( $dateTimeImmutable ) | array\||string | DateTimeImmutable
|
map_deep( ['1','2'], 'intval' ) | mixed | array<mixed>
|
wp_unslash( "O\'Brien" ) | array\||string | string
|
## Not included
esc_sql()/wpdb::_escape()have the same recursive shape and the same@return string|array … in the same type as suppliedpromise, but that promise is false:_real_escape()returns''for non-scalars andstringotherwise, soesc_sql( array( 1, 2 ) )returnsarray( '1', '2' ). It needs a different (string-producing) conditional and a docblock correction. Worth a separate ticket.urlencode_deep(),rawurlencode_deep(),urldecode_deep(),wp_kses_post_deep()are also string-producing rather than string-preserving.- The deprecated
wp_slash_strings_only(),addslashes_strings_only(), andaddslashes_gpc()are exact analogues, left alone intentionally.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Drafting the conditional return types and verifying them against PHPStan (including the map_deep()/wp_unslash() soundness repros), plus computing and decomposing the error-count deltas. Every claim in this description was checked by running PHPStan and PHP directly; the design decisions, the scope of the change, and this final text were reviewed and directed by me.
---
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.
🤖 Generated with Claude Code
This ticket was mentioned in PR #12465 on WordPress/wordpress-develop by @westonruter.
6 weeks ago
#53
Adds PHPStan array shapes and narrowed parameter/return types to wp_insert_term(), wp_update_term(), and wp_delete_term() in src/wp-includes/taxonomy.php. This removes the missingType.iterableValue errors reported for each function's $args parameter and return type at PHPStan level 10.
The types describe the contract these functions are *intended* to be called with, rather than what unguarded callers happen to pass today. Notably:
wp_update_term()does not accept a query string. Its@parampreviously documentedarray|string, butarray_merge( $term, $args )runs beforewp_parse_args(), so passing a string is aTypeError. Corrected toarray.wp_insert_term()andwp_delete_term()both callwp_parse_args()first, soarray|stringremains correct for those.parentis typednon-negative-int.sanitize_term_field()putsparentin$int_fieldsand silently clamps a negative value to0, reparenting the term to the root. Anumeric-stringalternative would be redundant: when$argsis a query string it matches thestringbranch of the union, so the array shape only constrains callers passing an actual array, where anintis what should be supplied.wp_delete_term()'sdefaultis typedpositive-int, since0is discarded by the subsequentterm_exists()check.- The
$argsshapes forwp_insert_term()andwp_update_term()are unsealed (...), because$argsis forwarded to thepre_insert_term,wp_insert_term_data,wp_insert_term_duplicate_term_check,create_term,wp_update_term_data, andedit_termhooks, where plugins legitimately read arbitrary keys.wp_delete_term()'s shape is sealed, as its$argsis consumed locally and never passed to a hook. descriptionisstring|nullforwp_insert_term()butstringforwp_update_term(). This asymmetry is deliberate:wp_insert_term()explicitly coerces a null description (// Coerce null description to strings, to avoid database errors.), whilewp_update_term()has no such coercion.wp_delete_term()'s return is narrowed tobool|WP_Error|0, verified against all six return paths. This relies on theterm_exists()conditional return type added in r62680.
These annotations surface new argument.type errors at several core call sites that pass $_POST or otherwise unvalidated data directly into these functions (wp-admin/edit-tags.php, wp-admin/includes/ajax-actions.php, wp-includes/nav-menu.php, class-wp-rest-terms-controller.php, class-wp-xmlrpc-server.php). These are pre-existing latent issues that the types now make visible, and are left to be addressed separately.
The two term_id: mixed return errors that remain inside wp_insert_term() and wp_update_term() stem from apply_filters() being typed as returning mixed, and are addressed in a separate pull request.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Reviewing the annotations against the function bodies and every core call site by diffing PHPStan level 10 output before and after; identifying the incorrect array|string type on wp_update_term(), the null description/slug values, the unsealed-shape requirement, and the redundant numeric-string alternatives; and authoring the final non-negative-int/positive-int narrowings. All changes were reviewed, tested, and are owned by me.
---
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.
🤖 Generated with Claude Code
This ticket was mentioned in PR #12485 on WordPress/wordpress-develop by @westonruter.
6 weeks ago
#55
This improves the static analysis types for the post retrieval functions, so that the type of the returned value can be determined from the requested $output.
## Conditional return types
The @phpstan-return types of get_post(), get_page(), get_page_by_path(), get_children(), and wp_get_recent_posts() are narrowed so that the ARRAY_A and ARRAY_N outputs are typed as non-empty-array. This is consistent with WP_Post::to_array(), which always returns at least the object's declared properties and therefore can never return an empty array.
wp_get_post_revision() previously had no conditional return type at all, so its return value was seen as WP_Post|array|null regardless of the requested $output. Describing it precisely resolves 30 pre-existing PHPStan errors in its callers — mostly Cannot access property $ID on array|WP_Post — across wp_restore_post_revision(), wp_delete_post_revision(), wp_xmlrpc_server::wp_restoreRevision(), and wp-admin/revision.php.
Note that non-empty-array is a PHPStan-specific type which phpDocumentor does not understand, so the plain @return tag for WP_Post::to_array() continues to use array<string, mixed>, with the narrower type supplied via @phpstan-return.
## Behavior changes
Two small runtime changes were needed to support the above:
trackback_url_list()now bails whenget_post()returnsnull. Previously, calling it with an invalid post ID would fall through to$postdata['post_excerpt'], emitting "Trying to access array offset on value of type null" warnings.wp_get_recent_posts()now collects itsARRAY_Aoutput in a separate$postsvariable rather than overwriting theWP_Postobjects in$resultsin place. Reassigning array values over the elements of$resultsleft the variable holding a mix ofWP_Postobjects and arrays partway through the loop, so static analysis could only ever inferarray<WP_Post|array<string, mixed>>for it. Building up a dedicated variable guarantees that everything it contains is an array. The returned value is unchanged.
## A note on list<>
It may be tempting to type these array returns as list<…> rather than array<int, …>, but that would not be sound. The the_posts, posts_results, and posts_pre_query filters allow plugins to return arbitrary arrays — an array_filter() in a the_posts callback is enough to produce a sparse numeric array — and core never re-indexes the result. array<int, …> is therefore the honest type. (get_children() is keyed by post ID in any case.)
## Testing instructions
No behavior change is expected beyond the removal of the PHP warnings noted above. The existing tests pass:
npm run test:php -- tests/phpunit/tests/post.php npm run test:php -- tests/phpunit/tests/post/revisions.php
Static analysis was verified to introduce no new errors on the changed files (and to fix 30 existing ones).
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Reviewing the change for correctness and consistency, extending it to wp_get_post_revision() and WP_Post::to_array(), verifying with PHPStan/PHPCS/PHPUnit, and drafting the commit message and this description. All changes were reviewed and edited by me.
This ticket was mentioned in PR #12488 on WordPress/wordpress-develop by @westonruter.
5 weeks ago
#57
Teaches static analysis that certain functions never come back, so it knows the code following a call to them is unreachable — and, where returning depends on a parameter, describes each case exactly instead of over-widening to the union of both.
Native never is PHP 8.1+, so all type work here is PHPDoc-only. Aside from two bug fixes noted at the end, there are no behavioral changes.
wp_die() itself is already typed ( $args is array{exit: false} ? void : never ) on trunk. This branch extends that idea outward to the functions built on top of it, then uses the result.
## 1. @return never on functions that always terminate (30)
The core primitives first, since almost everything else depends on them: wp_send_json(), wp_send_json_success(), wp_send_json_error(), wp_nonce_ays(), do_favicon(), and WP_Ajax_Response::send().
Then the rest: six WP_Customize_Manager methods (wp_die(), save(), refresh_nonces() and three request handlers), three WP_Customize_Nav_Menus Ajax methods, WP_Customize_Widgets::wp_ajax_update_widget(), two Custom_Background and three Custom_Image_Header Ajax methods, WP_List_Table::ajax_response(), comment_footer_die(), redirect_post(), install_theme_information(), WP_Plugin_Dependencies::check_plugin_dependencies_during_ajax(), graceful_fail(), wpmu_admin_do_redirect(), ms_not_installed(), and WP_Sitemaps_Stylesheet::render_stylesheet().
Candidates were found by walking the AST of every non-third-party file under src/ and keeping only declarations that contain no return statement in their own scope *and* terminate on every branch. Each survivor was then independently confirmed by PHPStan, which reports return.never when a @return never is wrong — both for an explicit return and for falling off the end.
Deliberately excluded:
- The
wp_ajax_*handlers inajax-actions.php. They do always terminate, but nothing calls them directly, so the annotation would buy no call-site narrowing. IXR_Server::error()/output()/serve(). IXR is vendored third-party code, andwp_xmlrpc_serverextendsIXR_Serverwhile overridingerror()with a method that *can* return. Sinceneveris a bottom type, annotating the parent would break return-type covariance.WP_List_Table::get_columns()/prepare_items()/ajax_user_can()andWP_Widget::widget(). Thesedie()with a "must be overridden in a subclass" message, but they are abstract by convention — subclasses return real values.
## 2. Conditional return types (13)
The seven wp_die() handlers — _default_wp_die_handler(), _ajax_wp_die_handler(), _json_wp_die_handler(), _jsonp_wp_die_handler(), _xmlrpc_wp_die_handler(), _xml_wp_die_handler() and _scalar_wp_die_handler() — each gain the same conditional wp_die() already carries:
@phpstan-return ( $args is array{exit: false} ? void : never )
Six more functions terminate on one path and return on another, with a parameter deciding which:
| Function | Conditional type | Why |
|---|---|---|
trackback_response() | ($error is 1\||true ? never : void) | Dies after emitting the error response when $error is truthy.
|
wp_protect_special_option() | ($option is 'alloptions'\||'notoptions' ? never : void) | Dies for the protected option names. |
redirect_canonical() | ($do_redirect is true ? null : string\||null) | With $do_redirect true it either exits or returns null; the string is unreachable. Only the recursive redirect_canonical( $url, false ) call can produce one.
|
check_admin_referer() | ($action is -1 ? int\||false : int) | false is only reachable in the deprecated no-action case. Callers passing a real action get an int or do not come back at all.
|
check_ajax_referer() | ($stop is true ? int : int\||false) | Same, keyed on $stop, which defaults to true.
|
get_cli_args() | ($required is true ? string\||true : string\||true\||null) | With $required true the function exits rather than returning null.
|
redirect_canonical()'s @return already *said in prose* "Never returns if a redirect occurs, depending on $do_redirect" — this just encodes it.
never is claimed only for parameter values where termination is certain. Over-declaring void where the function actually dies merely loses precision at the call site; claiming never where the function can return would be unsound and would mark live code unreachable.
get_cli_args() also declared @return string|true|null|never. never is the bottom type, so in a union it is absorbed and said nothing; the exit is now described in prose instead.
## 3. wp_die() PHPDoc
$message has always accepted an int — the legacy Ajax protocol responds with -1 (not permitted), 0 (failure) and 1 (success), and check_ajax_referer() dies with -1 — but the type was documented as string|WP_Error. Now string|WP_Error|int, with the @param block realigned.
The PHPStan range is int<-1, max> rather than int<-1, 1>: core also calls wp_die( time() ) in six places in ajax-actions.php, sending a Unix timestamp as the response body. -1 is the only negative value passed anywhere in core.
## 4. Short-circuits in ajax-actions.php
With wp_die() and wp_send_json_error() typed as terminating, PHPStan can see where get_post() may return null and the result is dereferenced anyway. Four handlers gained a guard:
wp_ajax_add_meta()— folded! $postinto the existing capability check.$postis only dereferenced in the *add* branch ($post->post_status,$post->post_type); the *update* branch finds the row bymeta_idand never touches it, so guarding earlier would have broken a working path.wp_ajax_inline_save()—wp_die()before$post['post_content'].wp_ajax_save_attachment()andwp_ajax_save_attachment_compat()—wp_send_json_error()before$post['post_type'], matching the failure idiom immediately below in each.
All four sit behind current_user_can( 'edit_post', … ), which already returns false for a nonexistent post (map_meta_cap() maps it to do_not_allow), so these are unreachable in practice today. They are defensive narrowing that also covers the delete-between-check-and-fetch race. This is what unlocks the 190 fixed errors in this file.
## 5. Two bug fixes
src/wp-admin/network.php built its "You must define the WP_ALLOW_MULTISITE constant" notice with printf() instead of sprintf(). printf() writes to the output buffer immediately and returns a byte count, so the message was emitted bare — ahead of wp_die()'s own markup — and wp_die() then rendered the integer byte count as its message body. Every other wp_die() call in core uses sprintf(); this was the only outlier. Surfaced by PHPStan once wp_die() had a precise $message type.
wp_ajax_save_attachment()/_compat() and friends — see §4; the missing null guards were real, if currently unreachable.
## PHPStan impact
Both passes run on a cold result cache. This matters: a warm cache can serve a stale result for a file whose PHPDoc the branch edited and silently drop findings on exactly the code under review, which is a correctness problem rather than merely a staleness one.
| Metric | Value |
|---|---|
Errors on trunk | 33,128 |
| Errors on this branch | 32,859 |
| Net change | −269 |
| Errors fixed | 308 |
| Errors introduced | 39 |
| Reconciliation | ✓ (308 − 39 = 269) |
Fixed, by identifier: 211 argument.type, 37 missingType.return, 20 property.nonObject, 15 offsetAccess.nonOffsetAccessible, 8 offsetAccess.notFound, 4 binaryOp.invalid, 4 variable.undefined, 4 offsetAccess.invalidOffset, 3 foreach.nonIterable, 1 missingType.iterableValue, 1 assign.propertyType.
Most-improved files: ajax-actions.php (190), privacy-tools.php (39), functions.php (12), class-wp-customize-nav-menus.php (11), then comment.php, class-wp-customize-manager.php and class-wp-customize-selective-refresh.php at 10 each.
### The 39 "introduced" errors are not 39 regressions
- ~15 are the same errors re-worded. On trunk,
src/wp-admin/comment.php:304reads *"Cannot access property$comment_post_IDonarray|WP_Comment|null"*; on this branch it reads *"…onarray|WP_Comment"*. Becausewp_die()afterif ( ! $comment )is nownever, the null branch is correctly pruned — the type gets strictly better. A diff keyed on message text counts an improved message as one fixed plus one introduced. The underlyingarray|WP_Commentcomplaint is pre-existing, fromget_comment( $id, ARRAY_A ).
- 10 are genuinely unreachable code, correctly identified:
src/wp-admin/includes/ajax-actions.php: 98, 2711, 2856 src/wp-admin/post.php: 128, 248 src/wp-includes/class-wp-customize-manager.php: 1939, 3169, 3200 src/wp-includes/pluggable.php: 1396 src/wp-includes/sitemaps/class-wp-sitemaps.php: 187
For example
ajax-actions.php:98is$wp_list_table->ajax_response(); wp_die( 0 );—ajax_response()already ends inwp_die(), so the trailingwp_die( 0 )can never run. Likewisepost.php:128isredirect_post( $post_id ); exit;. These are harmless belt-and-braces terminators rather than bugs, so they are left alone here and can be cleaned up separately.
- 3 are in
canonical.php— the nestedlowercase_octets()helper insideredirect_canonical(), now reachable for analysis.
- The remainder is latent looseness at
wp_die()call sites (mixed,string|false,string|nullpassed as$message) that the newly-precise signature makes visible.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Substantially authored by AI. The AI wrote an AST-based analyzer to find every function under src/ whose control-flow paths all terminate, applied the annotations, and verified each against PHPStan — including negative controls proving PHPStan rejects a wrong @return never, and PHPStan\dumpType probes proving each conditional type resolves as intended. It also found the network.php printf()/sprintf() bug. I have reviewed and take responsibility for all of it; the exclusion decisions (Ajax handlers, IXR, the "must be overridden" stubs) and the int<-1, max> range were settled in review.
This ticket was mentioned in PR #12491 on WordPress/wordpress-develop by @westonruter.
5 weeks ago
#61
Improves PHPStan static-analysis coverage for WP_Post, tightening the types of its array form and properties to what the code actually guarantees.
## Changes
WP_Post::to_array()— adds aData_Array@phpstan-typeshape describing every key it returns, used as the method's@phpstan-return. The shape is open (...) becauseWP_Postis#[AllowDynamicProperties]and instances routinely carry extra columns, so sealing it would produce false "offset does not exist" errors.- Refined value types, only where the value set is genuinely closed:
ID,post_parent→non-negative-int(never negative;0is valid for unsaved objects, so deliberately notpositive-int).post_author,comment_count→numeric-string.post_date(_gmt),post_modified(_gmt)→non-empty-string(NOT NULLDATETIMEcolumns).- The always-present magic keys
ancestors,page_template,post_category,tags_input—WP_Post::__isset()returnstruefor all four, soto_array()always appends them. - Fields whose values are extensible by plugins (
post_status,post_type,comment_status,ping_status) are intentionally left asstring.
WP_Post::$filter— corrected tostring|null/ the six sanitize contexts plus'sample'plusnull. It had been mistyped (asstring, then as the six contexts), both of which wrongly excludednull(aWP_Postbuilt from a raw row has nofilteruntil sanitized, and core relies on this viaisset()/empty()checks) and'sample'(assigned byget_sample_permalink()since [8526] / WP 2.7.1 to keep the mutated object out of the cache).sanitize_post_field()'s$contextis widened to accept'sample'accordingly — it already handles it at runtime, falling through to thedisplaybranch.get_post_ancestors()→list<non-negative-int>return type.trackback_url_list()— fetches theWP_Postand callsto_array()so the accessed keys are known, guarding against anullpost.WP_Customize_Nav_Menu_Item_Setting— casts theintfromget_current_user_id()tostringto honor thenumeric-stringpost_author.
## PHPStan impact (level 10)
Net −8 errors (24 fixed, 16 "introduced").
The 16 "introduced" are not regressions. They are pre-existing latent errors at distant call sites (e.g. esc_attr( $post->ID ), isset( $post->ID )) that PHPStan now prints with the sharper int<0, max> type instead of int — each has a byte-identical counterpart on trunk at the same line, so a message-based diff counts one re-worded error as one fixed + one introduced. None of those sites is in a file this PR edits.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Static-analysis-driven iteration and drafting the PHPDoc/type annotations. Every change was reviewed, verified against PHPStan (level 10) and PHPCS, and edited by me; I take responsibility for the result.
This ticket was mentioned in PR #12497 on WordPress/wordpress-develop by @Soean.
5 weeks ago
#62
Both wp_send_json_error() and WP_Customize_Manager::wp_die() are documented with @return never and always terminate execution, so a subsequent return statement is never reached.
This also makes the class more consistent, as other wp_send_json_error() calls in the same class, e.g. for invalid_nonce and changeset_locked in handle_changeset_trash_request(), are already not followed by a return statement.
Follow-up to [62704]
Trac ticket: https://core.trac.wordpress.org/ticket/64898
@mukesh27 commented on PR #12497:
5 weeks ago
#63
Follow-up of https://core.trac.wordpress.org/changeset/62704
@westonruter commented on PR #12491:
5 weeks ago
#64
👉🏻 In a follow-up ticket, there are fixes needed for get_default_post_to_edit(). It is erroneously setting a read-only post_category, it is setting post_author to an empty string when it could be set to '0', and it is setting post_date and post_date_gmt to empty strings when they should be '0000-00-00 00:00:00'. This would allow undoing some of d10c9d4b3938d2440446c9a2a2d0047d7a7791d4 to re-narrow those types.
This ticket was mentioned in PR #12441 on WordPress/wordpress-develop by @johnbillion.
5 weeks ago
#67
This adds a Hook_Callback PHPStan type to represent the shape of a callback used internally in WP_Hook and used in return values of array access methods.
Also narrows a few other documented types.
@johnbillion commented on PR #12441:
5 weeks ago
#69
This ticket was mentioned in PR #12588 on WordPress/wordpress-develop by @westonruter.
5 weeks ago
#72
The wp_parse_list(), wp_parse_id_list(), and wp_parse_slug_list() functions are key utility functions. The wp_parse_id_list() function was used in a security fix for 7.0.2 in r62771 (97b5a75246f6c82fe9d9f0ba492f06d93b602b98). However, these functions can be further hardened to give precise types and to address various PHPStan errors at rule level 10:
wp_parse_list()(5027–5029)
missingType.iterableValue×2 —$input_listparam and return are barearray.return.type—preg_split()can returnfalse, which isn't in the declaredarrayreturn.
wp_parse_id_list()(5047)
missingType.iterableValue— barearrayparam.
wp_parse_slug_list()(5062, 5065)
missingType.iterableValue— barearrayparam.argument.type—array_map( 'sanitize_title', ... ):sanitize_title()'s first param isstring, but the array's value type ismixed, so it isn't accepted ascallable(mixed): mixed. (absintinwp_parse_id_listdoesn't trip this because its param ismixed.)
Furthermore, these functions do not consistently return lists. The use of array_filter() can cause sparse arrays to be returned unexpectedly, and also for passed associative to have their keys passed through. (⚠️ Maybe this is done intentionally???)
Trac ticket: https://core.trac.wordpress.org/ticket/64898
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 4.8
Used for: Coming up with some test assertion logic. Obtaining PHPStan errors.
This ticket was mentioned in PR #12597 on WordPress/wordpress-develop by @westonruter.
4 weeks ago
#74
Updates two PHPStan Composer dependencies used for static analysis:
phpstan/phpstan:2.2.2→2.2.5phpstan/phpstan-phpunit:2.0.16→2.0.18
### phpstan/phpstan (2.2.2 → 2.2.5)
2.2.3
- Improvements: result cache now invalidates only package-dependent files on a Composer package change; type predicates supported on
callable/Closuretypes; several regex/string-narrowing improvements (e.g.::classin array shape keys,ob_get_contents()et al. narrowed to non-falseduring output buffering). - Bugfixes: 52 issues fixed, including several
BooleanAnd/BooleanOrconditional-expression-holder edge cases,matchsubject narrowing, andpreg_match_all()return type inference. - Performance: multiple hot-path optimizations in the type system and scope resolution, plus parallel analysis job striping improvements.
2.2.4 — "RAMpocalypse": substantially reduced memory usage (PHPStan analysing itself dropped from 2.8 GB to 2.1 GB total worker memory), plus supporting bugfixes for boolean conditional holders and output-buffer tracking.
2.2.5
- Improvements: updated
nikic/PHP-Parserto 5.8.0; narrowsexplode()when the delimiter is a known substring. - Bugfixes: allows
nullvalues in the nativepreg_replace_callbackcallback array type whenPREG_UNMATCHED_AS_NULLis used. - Performance: further caching/LRU-capping work (member cache, resolved type aliases, cached parser sources) and PHP 8.5
partitionedcookie attribute support.
Full details: https://github.com/phpstan/phpstan/releases
### phpstan/phpstan-phpunit (2.0.16 → 2.0.18)
2.0.17 — Adds ClassAttributeRequiresPhpVersionRule and sanity/range checking for #[RequiresPhp]/#[RequiresPhpunit] attribute values (the rest of the changes are CI/tooling maintenance).
2.0.18 — Fixes #[RequiresPhpunit] version requirements being evaluated against the PHP version instead of the PHPUnit version, and adds default values for the bool parameters of AttributeVersionRequirementHelper.
Full details: https://github.com/phpstan/phpstan-phpunit/releases
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Sonnet 5
Used for: Drafting this pull request description (summarizing upstream changelogs); the dependency version bumps themselves were made directly by me.
@westonruter commented on PR #12597:
4 weeks ago
#75
Note: These two dependencies were updated 2 weeks ago.
This ticket was mentioned in PR #12606 on WordPress/wordpress-develop by @westonruter.
4 weeks ago
#77
Improves type specificity across the comment APIs, continuing the work done for posts in r62437, r62648, r62694], and r62717.
## WP_Comment
- Adds a
Data_Arrayarray shape describing the keys returned byWP_Comment::to_array(), and narrows the properties it covers:comment_approvedbecomesnon-empty-string, and the values core uses for it and forcomment_typeare documented.comment_typeis deliberately not narrowed, because comments created before 5.5.0 may store an empty string rather thancomment, which is whyget_comment_type()normalizes that case on read. - Declares the 21 post fields that
WP_Comment::__get()proxies to the comment's post as@property-read, typed to match the correspondingWP_Postproperty. These were previously invisible to static analysis, IDE completion, and the generated documentation. - Corrects
$children, which is null untilget_children()populates it, and replaces thearray<int|numeric-string, WP_Comment>key type used across these APIs witharray<int, WP_Comment>. Anumeric-stringarray key cannot exist in PHP, since numeric string keys are coerced to integers on assignment.
## WP_Comment::get_children()
- Adds conditional return types for the
count,fields, andformatmodes, and documents every argument core or the plugin ecosystem actually passes:fields,count,type,number,post_id, andorder. Several were already in use but undocumented. - A
countorfieldsquery now returns its result directly rather than storing it in the children cache. That cache holdsWP_Commentobjects and is read back byadd_child(),get_child(), and theflatformat, so writing an integer or a list of IDs into it left the object returning the wrong thing on a subsequent call. This resolves aTODOleft in the method.
One commit in this branch, "Add native return types", added an array return type to get_children(), which made the method fatal when asked for a count. WP_REST_Comments_Controller::prepare_links() calls it with count => true, so this raised TypeError: WP_Comment::get_children(): Return value must be of type array, int returned and failed 102 of the REST comment tests. It is reverted later in the branch, but is left in the history rather than rebased away, since the fix is easier to follow with the mistake visible.
## WP_Comment_Query and get_comments()
- Adds conditional return types to
get_comments()andget_approved_comments(), keyed oncountandfields.get_approved_comments()is keyed on$post_idfirst, since it returns an empty array when that is falsey, before$argsis parsed at all. - Narrows comment ID arrays to
non-negative-int[], and typesWP_Comment_Query::$commentsas null until a query is run.WP_Comment_Query::__construct()only runs a query when given one, so a barenew WP_Comment_Query()leaves the property unset.
## Known gaps
Eight PHPStan errors remain inside WP_Comment_Query::get_comments(), from two causes: (int) and intval() do not express what the unsigned schema guarantees, and the comment ID cache is read back as mixed. Both are fixable together in a follow-up. Switching the casts to absint() resolves the first half, but is a runtime change and is out of scope here.
The comments_pre_query filter can return an arbitrarily shaped array that is assigned straight to $comments, so the declared type for that property describes intent rather than a guarantee.
## Testing
phpstan-diff is clean on the changed lines, PHPCS reports nothing new, and the comment, note, and REST comment suites pass locally (540, 78, and 200 tests respectively). The full suite has not been run locally and is left to CI.
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: A review of the already-committed typing work, which surfaced most of the issues fixed here, and drafting of the annotations, docblocks, and commit messages. Every change was reviewed and frequently rewritten by me, and several of the better solutions are mine rather than the model's, including splitting the get_children() guard into separate branches so each narrows independently, and asserting count as present-and-false rather than absent so the conditional return type resolves. The model also asserted at one point that core's REST controller passed only documented arguments, which was wrong and which I caught; verifying that claim is what uncovered the TypeError described above.
This ticket was mentioned in PR #12618 on WordPress/wordpress-develop by @westonruter.
4 weeks ago
#78
Trac ticket: https://core.trac.wordpress.org/ticket/64898
Fixes:
- Missing types
- Erroneous fallback case for
MockAction::current_filter()in how it gets the last key of$wp_actions. - Fix
MockAction::get_call_count()to handle getting counts of a provided filter.
## Use of AI Tools
None
This ticket was mentioned in PR #12725 on WordPress/wordpress-develop by @westonruter.
3 weeks ago
#85
Trac ticket: https://core.trac.wordpress.org/ticket/64898
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5
Used for:
- Troubleshooting PHPStan complaining that the
data_wp_bind_processormethod was unused. See 1638ca3c44b7a02b7a1cf3a12233e088b1b1dcbd.
@westonruter commented on PR #12725:
3 weeks ago
#86
@luisherranz Would you please push up commits to this branch that apply your suggestions?
Another option to consider would be to replicate in PHP what JavaScript's
String()does. An object becomes[object Object], an array becomes a comma join, and INF and NAN become"Infinity"and"NaN", although I'm not sure if, especially the[object Object]part, would make much sense.
IMO, it would be better to have a warning so that the author would be more readily discover their usage error.
@westonruter commented on PR #12725:
3 weeks ago
#88
This is a bit unexpected to me , but it also is the behavior in trunk, which I just realized.
@luisherranz I think I got it. How about 0d8e02388419a7fc21e9d08c96ef6f61f6483ec4?
@luisherranz commented on PR #12725:
3 weeks ago
#90
@luisherranz I think I got it. How about https://github.com/WordPress/wordpress-develop/commit/0d8e02388419a7fc21e9d08c96ef6f61f6483ec4?
Looks great! I'll prepare the fix to sync it in Gutenberg, thank you very much! 🙂
@luisherranz commented on PR #12725:
3 weeks ago
#91
Gutenberg PR for the syntax backport here:
This ticket was mentioned in Slack in #core by adrianduffell. View the logs.
2 weeks ago
#97
@
2 weeks ago
- Resolution → fixed
- Status new → closed
With 7.1 RC1 due out today, I'm going to close this one out. This can be reopened if anything comes up that needs addressing for 7.1.
I've created #65817 for the 7.2 cycle.
#99
@
4 days ago
PR which fixes static analysis errors in the 7.1 release cycle which had been baselined: https://github.com/WordPress/wordpress-develop/pull/13064
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
This pull request makes a small change to the
check_import_new_usersfunction, simplifying its logic to directly return the result of the permission check.Trac ticket: https://core.trac.wordpress.org/ticket/64898