Code Quality: Add PHPStan conditional return types to the slashing and unslashing functions#12455
Conversation
A bare `@phpstan-return T` asserts that a function returns its input
unchanged. That is not what these functions do, and PHPStan believed it:
* `map_deep( array( '1', '2' ), 'intval' )` was inferred as
`array{'1', '2'}` rather than `array{int, int}`, because the callback is
arbitrary and may change the type of every leaf.
* `wp_unslash( "O\'Brien" )` kept the literal type `'O\'Brien'`, even
though the function returns `'O'Brien'`. String literals must widen.
Replace them with conditional return types that describe the actual
behavior:
* `map_deep()` returns an array for an array, the same instance for an
object (it mutates in place and returns it), and `mixed` for a scalar,
since only the callback decides the leaf type.
* `stripslashes_deep()`, `wp_slash()`, and `wp_unslash()` alter strings
and nothing else, so every other type passes through as `T`, and array
values are mapped by the same string-to-string rule.
* `add_magic_quotes()` gains the same treatment, so a precisely typed
superglobal survives `wp_magic_quotes()` instead of being flattened.
`wp_slash()` needs the template for a second reason: its own
`array_map( 'wp_slash', $value )` recursion requires a parameter that
accepts `mixed`, which the narrow `@param string|array` denied. That was
already reported on trunk as `Parameter #1 $callback of function
array_map expects (callable(mixed): mixed)|null, 'wp_slash' given.`
`key-of<T>` is not yet enforced when `T` is a template type of array
(phpstan/phpstan#14571), and `value-of<T>` will only sharpen once
superglobals carry precise types. Both accurately describe the runtime
behavior today: keys are preserved, and `$_GET`/`$_POST` leaves are
always strings.
Co-Authored-By: Claude Opus 4.8 <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. |
…nctions. Adds `@phpstan-` prefixed generics and conditional return types to `map_deep()`, `stripslashes_from_strings_only()`, `stripslashes_deep()`, `wp_slash()`, `wp_unslash()`, and `add_magic_quotes()`. Static analysis now knows that a `string` passed to the slashing functions yields a `string`, and an `array` yields an `array`; this is something the plain `@return` tags could not express, and which caused `wp_unslash()` to widen its callers' types to `array|string` even after an `is_array()` guard. The returns are conditional rather than a bare `T` for two reasons: `map_deep()`'s callback may change the type of every leaf, and the slashing functions rewrite string contents, so a literal-string type cannot survive. Developed in #12455. See #64898. git-svn-id: https://develop.svn.wordpress.org/trunk@62672 602fd350-edb4-49c9-b593-d223f7449a82
…nctions. Adds `@phpstan-` prefixed generics and conditional return types to `map_deep()`, `stripslashes_from_strings_only()`, `stripslashes_deep()`, `wp_slash()`, `wp_unslash()`, and `add_magic_quotes()`. Static analysis now knows that a `string` passed to the slashing functions yields a `string`, and an `array` yields an `array`; this is something the plain `@return` tags could not express, and which caused `wp_unslash()` to widen its callers' types to `array|string` even after an `is_array()` guard. The returns are conditional rather than a bare `T` for two reasons: `map_deep()`'s callback may change the type of every leaf, and the slashing functions rewrite string contents, so a literal-string type cannot survive. Developed in WordPress/wordpress-develop#12455. See #64898. Built from https://develop.svn.wordpress.org/trunk@62672 git-svn-id: http://core.svn.wordpress.org/trunk@61957 1a063a9b-81f0-0310-95a4-ce76da25c4cd
✅ Committed in r62672 (fb532ba)
Adds
@phpstan-prefixed generics and conditional return types to the six functions in the slashing/unslashing family, so that static analysis knows astringin yields astringout and anarrayin yields anarrayout.No runtime code changes. This PR only touches docblocks. The plain
@param/@returntags 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(), andstripslashes_deep()all promise "in the same type as supplied", but@return string|arraycannot express that. Passing astringgets you backarray|string, which then poisons every downstream call:PHPStan reports
Argument of an invalid type array|string supplied for foreach, only iterables are supported.— becausewp_unslash()is declared as possibly returning astring, even though the caller just proved the input is an array.There is also a pre-existing error on trunk inside
wp_slash()itself:wp_slash()recurses viaarray_map( 'wp_slash', $value ), which hands each element towp_slash()asmixed. The narrow@param string|arraydenies 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 acceptingmixedis 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:Both are fixed by the conditional return types below.
stripslashes_from_strings_only()demonstrates why the conditional is required rather than a plainT: with@phpstan-return T, PHPStan correctly rejects the body withshould return T but returns string|T of mixed, becauseTcould be a literal-string subtype whilestripslashes()returns plainstring.The changes
map_deep()array→array,object→T, elsemixedTbecausemap_deep()mutates in place and returns the same instance.stripslashes_from_strings_only()(T is string ? string : T)stripslashes_deep()T.wp_slash()array_maperror above.wp_unslash()add_magic_quotes()T of array, array values mappedwp_magic_quotes().The shared array-value shape is:
On
key-of<T>andvalue-of<T>key-of<T>is currently a no-op whenTis 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 yieldsmixedfor superglobal data, because PHPStan models$_GET/$_POSTasarray<mixed>. PHP guarantees their leaves are alwaysstring(?input[post][id]=1producesstring("1"), neverint), so this too will sharpen if superglobals ever carry precise types.Impact on PHPStan error counts
Measured with
level: 10locally (the committedphpstan.neon.distruns atlevel: 0, so CI totals are unaffected). Whole-repo counts:wp_unslash()/wp_slash()parameter checksThe −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.nonIterableonarray|stringdrops from 11 to 9:2. Silenced, not fixed (294 errors) — adding
@phpstan-param T $valueoverrides the plain@param string|array, so PHPStan stops checking the argument entirely:These 294 are an artifact of PHPStan typing superglobals as
array<mixed>; if$_POST['key']werestring|array<…>it would satisfy@param string|arrayand 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:
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: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
return.typeerrors.composer lint(PHPCS / WordPress Coding Standards) is clean on both changed files.wp_unslash( $string )array|stringstringwp_unslash( $_POST['k'] )afteris_array()array|stringarray<mixed>wp_slash( array<int, int> )array|stringarray<int>wp_slash( $dateTimeImmutable )array|stringDateTimeImmutablemap_deep( ['1','2'], 'intval' )mixedarray<mixed>wp_unslash( "O\'Brien" )array|stringstringNot 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.wp_slash_strings_only(),addslashes_strings_only(), andaddslashes_gpc()are exact analogues, left alone intentionally.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 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
https://claude.ai/code/session_01LFXAppq3f99RVSAHaHHMB6