Posts, Post Types: Add conditional return types to post retrieval, sanitization, and insertion functions#12426
Posts, Post Types: Add conditional return types to post retrieval, sanitization, and insertion functions#12426westonruter wants to merge 11 commits into
Conversation
… sanitization. Annotate `get_post()`, `get_post_field()`, `get_post_types()`, `sanitize_post()`, `sanitize_post_field()`, `WP_Post::filter()`, and `WP_Post::get_instance()` with `@phpstan-param`/`@phpstan-return` tags so the analyzed return type reflects the value of the `$output`, `$field`, `$context`, and `$filter` arguments. `WP_Post::$filter` and `WP_Post::to_array()` gain matching annotations. While tightening the types, a few genuinely loose spots surfaced by the stricter analysis are addressed: * `WP_Post::get_instance()` treats a cached value as a hit only when it is a `stdClass` or `WP_Post` instance rather than any truthy value, and casts the ID to `int` before caching. * `WP_Post::get_category()`/`get_tags()` bail when `get_the_terms()` returns a `WP_Error`, not just on an empty result. * `WP_Post::filter( 'raw' )` is documented and typed to return `false` when the underlying post has been deleted, and `get_post()` returns `null` in that case. * `get_post()` resolves numeric input through `WP_Post::get_instance()` and returns `null` for any other unrecognized input. * `sanitize_post()`/`sanitize_post_field()` cast to the documented field types ( `(int)` ID, `(array)` ancestors ) before use. The `sanitize_post()` object branch is typed as `stdClass|WP_Post` — the only object shapes the function is handed, and the only ones on which its dynamic property writes type-check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t_post_field()` type. `get_post_field()` returns `''` when the post cannot be resolved or the requested field is not set, before it ever reaches `sanitize_post_field()`. The `@phpstan-return` conditional therefore has to widen the integer branches to `int|''` and `non-negative-int[]|''`; otherwise a caller of `get_post_field( 'ID', ... )` would be typed as receiving an `int` yet could get `''` at runtime — masking, for example, a `TypeError` on `get_post_field( 'ID', $x ) + 1` when the post is missing. `sanitize_post_field()` itself keeps the tighter `int`/`non-negative-int[]` branches, since it has no such early return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…()`. `get_page_by_path()` returns whatever `get_post( $id, $output )` returns, or `null`, so its analyzed return type can be keyed on `$output` exactly as `get_post()` already is: an associative array for `ARRAY_A`, a numeric array for `ARRAY_N`, and a `WP_Post` otherwise, each nullable. `$output` and `$post_type` gain matching `@phpstan-param` tags. Since `get_post()`'s conditional branches already include `null`, the function's own `return null` paths are covered without widening. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sts()`. Key the analyzed return type on `$output`: for `ARRAY_A` each post is converted with `get_object_vars()`, so the result is `array<int, array<string, mixed>>` (an empty array, never `false`, on failure); otherwise the underlying `get_posts()` result is passed through as `WP_Post[]|false`. `$output` gains a matching `@phpstan-param` of `'OBJECT'|'ARRAY_A'`. Because `get_posts()` is already typed to return `WP_Post[]` unless `fields` is `'ids'` — which the parsed arguments here never statically are — the object branch resolves to `WP_Post` elements without further narrowing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`get_page()` is a deprecated alias that forwards `$page`, `$output`, and `$filter` to `get_post()` unchanged, so it carries the same `@phpstan-param`/`@phpstan-return` tags: the return type is keyed on `$output` (associative array for `ARRAY_A`, numeric array for `ARRAY_N`, `WP_Post` otherwise, each nullable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Key the analyzed return type on the request shape. When `$args` selects `fields => 'ids'` the function short-circuits and hands back the raw `get_posts()` result, so that case mirrors `get_posts()`'s own `int[]` branch. Otherwise the results are re-keyed by post ID and shaped by `$output`: `array<int, array<string, mixed>>` for `ARRAY_A`, `array<int, array<int, mixed>>` for `ARRAY_N`, and `WP_Post[]` otherwise. `$output` gains a matching `@phpstan-param`. The empty-array early returns (no ambient post, or no children found) are subtypes of every branch, and full analysis confirms none of the six return statements conflict with the declared type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
PHPStan impact:
|
| 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.
Analysis performed by Claude (Opus 4.8) via Claude Code, at the request of and reviewed by @westonruter.
Annotate the core post retrieval, sanitization, and insertion APIs with PHPStan conditional return types keyed on their mode arguments, so static analysis can resolve the concrete result at each call site: * `get_post()`, `get_page()`, `get_page_by_path()`, `get_children()`, and `get_post_types()` narrow on `$output`. * `get_posts()` and `WP_Query::query()` narrow on the `fields` argument. * `get_post_field()` and `sanitize_post_field()` narrow on `$field`. * `wp_insert_post()`, `wp_update_post()`, and `wp_insert_attachment()` narrow on `$wp_error`. Matching `@phpstan-param` tags are added where the analyzed return depends on a narrowed input. Separately, several previously generic or underspecified types are filled in where the precise type is known: * `WP_Post::to_array()` is typed `array<string, mixed>` rather than a bare `array`. * `WP_Post::$filter` is annotated with its recognized context values rather than a bare `string`. * `WP_Post::filter()` is corrected to return `WP_Post|false` (previously `WP_Post`), and its missing summary is documented. * `sanitize_post()`'s `$post` parameter is narrowed from `object` to `stdClass|WP_Post`. Because these functions were previously typed as returning `mixed` or a bare `object`, the sharper types let analysis see through to hundreds of downstream call sites, for a net reduction of roughly 280 PHPStan errors across the tree. A few supporting runtime changes make the types hold: * `get_post()` now guards against the `false` that `WP_Post::filter( 'raw' )` can return for a since-deleted post. * `WP_Post::get_instance()` tightens its cache-hit check. * post IDs are cast to `int` where passed on. Developed in #12426. Follow-up to r61789. See #64898, #64896. git-svn-id: https://develop.svn.wordpress.org/trunk@62648 602fd350-edb4-49c9-b593-d223f7449a82
Annotate the core post retrieval, sanitization, and insertion APIs with PHPStan conditional return types keyed on their mode arguments, so static analysis can resolve the concrete result at each call site: * `get_post()`, `get_page()`, `get_page_by_path()`, `get_children()`, and `get_post_types()` narrow on `$output`. * `get_posts()` and `WP_Query::query()` narrow on the `fields` argument. * `get_post_field()` and `sanitize_post_field()` narrow on `$field`. * `wp_insert_post()`, `wp_update_post()`, and `wp_insert_attachment()` narrow on `$wp_error`. Matching `@phpstan-param` tags are added where the analyzed return depends on a narrowed input. Separately, several previously generic or underspecified types are filled in where the precise type is known: * `WP_Post::to_array()` is typed `array<string, mixed>` rather than a bare `array`. * `WP_Post::$filter` is annotated with its recognized context values rather than a bare `string`. * `WP_Post::filter()` is corrected to return `WP_Post|false` (previously `WP_Post`), and its missing summary is documented. * `sanitize_post()`'s `$post` parameter is narrowed from `object` to `stdClass|WP_Post`. Because these functions were previously typed as returning `mixed` or a bare `object`, the sharper types let analysis see through to hundreds of downstream call sites, for a net reduction of roughly 280 PHPStan errors across the tree. A few supporting runtime changes make the types hold: * `get_post()` now guards against the `false` that `WP_Post::filter( 'raw' )` can return for a since-deleted post. * `WP_Post::get_instance()` tightens its cache-hit check. * post IDs are cast to `int` where passed on. Developed in WordPress/wordpress-develop#12426. Follow-up to r61789. See #64898, #64896. Built from https://develop.svn.wordpress.org/trunk@62648 git-svn-id: http://core.svn.wordpress.org/trunk@61933 1a063a9b-81f0-0310-95a4-ce76da25c4cd
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-returnkeyed 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-paramtags 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().(int)casts on post IDs passed toget_post()/get_instance().Verified with
phpstan-diffagainsttrunk: no new errors on changed lines.Trac ticket: https://core.trac.wordpress.org/ticket/64898
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.
✅ Committed in r62648 (578d09b)