From 559da7c864a64df7b2ff7df588cfdae4ffd86f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:27:12 +0000 Subject: [PATCH 1/5] Build/Test Tools: Teach PHPStan WordPress hash notation. Core documents the contents of an array argument with a nested list of `@type` tags. PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed, and a shape that should be visible to the analysis has to be written a second time as a `@phpstan-param` or `@phpstan-return` beside the hash that already describes it. `HashNotationVisitor` translates the hash into the array shapes PHPStan understands, so the documentation core already writes serves the reader and the analysis alike. Across `src/wp-admin`, `src/wp-includes` and the bundled themes it derives 394 tags from the 465 hashes it can see. A hash whose translation would be a guess is left alone, so the visitor only ever narrows a type and never contradicts one. A `@phpstan-param` or `@phpstan-return` written by hand always wins; the declared type has to name a bare `array`; the hash has to be well formed; and a parameter taken by reference is skipped, since PHPStan checks those in both directions and a shape there would constrain every caller's variable rather than describe what the function reads. Keys of a `@param` hash are optional and its shape is left open, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and its shape is sealed, since they describe a value core itself builds, unless the description marks one `Optional.` Two kinds of hash are left for later. A `@var` hash on a property is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does. An `object` hash, such as the one on `get_taxonomy_labels()`, would need the docblock to name the class rather than `object`, because PHPStan's object shapes are structural and one derived for a `stdClass` is no longer assignable to a property declared `stdClass`. The translation follows the one php-stubs/wordpress-stubs performs when generating stubs, which is how the WordPress flavor of PHPDoc reaches PHPStan today for plugins and themes. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- tests/phpstan/HashNotationVisitor.php | 635 ++++++++++++++++++++++++++ tests/phpstan/README.md | 33 ++ tests/phpstan/base.neon | 9 + 3 files changed, 677 insertions(+) create mode 100644 tests/phpstan/HashNotationVisitor.php diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php new file mode 100644 index 0000000000000..f175d55cb63f9 --- /dev/null +++ b/tests/phpstan/HashNotationVisitor.php @@ -0,0 +1,635 @@ +`, is left as written. An + * `object` hash is left alone as well: PHPStan's object shapes are + * structural, so one derived for a value core builds as a `stdClass` would + * no longer be assignable to a property declared `stdClass`. + * - The hash has to be well formed: every `{` closed by a `}` on a line of its + * own, and every `@type` carrying a type and a `$name`. Anything else, and + * the whole tag is skipped. + * - A parameter taken by reference is skipped. PHPStan checks a by-reference + * argument in both directions, so a shape there is a contract every caller's + * variable has to satisfy before the call, which is not what the hash says. + * + * Keys of a `@param` hash are optional, at every level, because a caller may + * pass any subset of them, and a shape whose keys were required would report + * every partial array as an error. Keys of a `@return` hash are required, since + * they describe a value core itself builds, unless the description marks one + * `Optional.` Numbered keys such as `$0`, `$1` used for positional arguments + * are required in either case. + * + * The shape of a `@param` hash is left open, with a trailing `...`, because the + * hash lists the keys core reads rather than the only keys a caller may pass. + * A sealed shape would report reading or testing for any other key as an error, + * and would contradict the conditional return types core writes by hand. The + * shape of a `@return` hash is sealed, so reading a key core does not document + * is reported rather than silently typed as `mixed`. + * + * @link https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays Hash notation in the documentation standards. + * @link https://github.com/php-stubs/wordpress-stubs/blob/master/src/Visitor.php The equivalent translation php-stubs/wordpress-stubs performs when generating stubs, MIT license. + * + * Registered as `phpstan.parser.richParserNodeVisitor` in `base.neon`. + */ +final class HashNotationVisitor extends NodeVisitorAbstract { + + /** + * Docblock tags whose description may carry a hash. + * + * `@var` is left out. A property declaration is inherited by every subclass + * and has to accept its own default, so a shape there would say more than + * the hash does: that no subclass may widen the property, and that the + * declared default already has the shape. + */ + private const HASH_TAGS = array( 'param', 'return' ); + + /** + * Translates the hashes in a node's docblock into `@phpstan-*` shapes. + * + * @param Node $node The node being entered. + * @return null + */ + public function enterNode( Node $node ): ?Node { + if ( ! $node instanceof Node\FunctionLike ) { + return null; + } + + $doc = $node->getDocComment(); + if ( null === $doc ) { + return null; + } + + $text = $doc->getText(); + if ( ! str_contains( $text, '@type ' ) ) { + return null; + } + + $additions = $this->build_additions( $text, $this->by_reference_parameters( $node ) ); + if ( array() === $additions ) { + return null; + } + + $lines = array(); + foreach ( $additions as $addition ) { + $lines[] = ' * ' . $addition; + } + + // Insert the derived tags just before the closing `*/`. + $merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $text, 1 ); + if ( ! is_string( $merged ) ) { + return null; + } + + $node->setDocComment( new Doc( $merged, $doc->getStartLine(), $doc->getStartFilePos() ) ); + + return null; + } + + /** + * Collects the parameters a function takes by reference. + * + * @param Node\FunctionLike $node Node the docblock is attached to. + * @return array Set of parameter names, without the `$`. + */ + private function by_reference_parameters( Node\FunctionLike $node ): array { + $names = array(); + foreach ( $node->getParams() as $param ) { + if ( $param->byRef && $param->var instanceof Node\Expr\Variable && is_string( $param->var->name ) ) { + $names[ $param->var->name ] = true; + } + } + + return $names; + } + + /** + * Builds the `@phpstan-*` tags derived from every hash in a docblock. + * + * @param string $text Raw docblock text including the `/**` markers. + * @param array $by_reference Parameters the function takes by reference. + * @return list Tag lines, without the leading ` * `. + */ + private function build_additions( string $text, array $by_reference ): array { + $additions = array(); + + foreach ( $this->split_tags( $text ) as $tag ) { + if ( ! in_array( $tag['name'], self::HASH_TAGS, true ) ) { + continue; + } + + $header = rtrim( $tag['header'] ); + if ( ! str_ends_with( $header, '{' ) ) { + continue; + } + + $head = rtrim( substr( $header, 0, -1 ) ); + $split = $this->split_type( $head ); + if ( null === $split ) { + continue; + } + + list( $declared, $remainder ) = $split; + + $variable = null; + if ( preg_match( '#^\$([A-Za-z0-9_]+)#', $remainder, $matches ) === 1 ) { + $variable = $matches[1]; + } + + // A `@param` hash without a variable name documents nothing PHPStan can attach a type to. + if ( 'param' === $tag['name'] && null === $variable ) { + continue; + } + + /* + * A by-reference parameter is checked in both directions, so a shape + * derived for one would have to be reached by every caller's variable + * before the call. The hash describes what the function reads, not a + * contract on the caller's variable, so it is left out. + */ + if ( 'param' === $tag['name'] && isset( $by_reference[ (string) $variable ] ) ) { + continue; + } + + if ( $this->has_phpstan_counterpart( $text, $tag['name'], $variable ) ) { + continue; + } + + $index = 0; + $entries = $this->parse_entries( $tag['body'], $index ); + if ( null === $entries || array() === $entries ) { + continue; + } + + $type = $this->substitute( $declared, $entries, 'param' === $tag['name'] ); + if ( null === $type ) { + continue; + } + + $additions[] = sprintf( + '@phpstan-%s %s%s', + $tag['name'], + $type, + null !== $variable && 'return' !== $tag['name'] ? ' $' . $variable : '' + ); + } + + return $additions; + } + + /** + * Splits a docblock into its tags. + * + * The docblock furniture is removed first, so a line reads as it would in a + * plain text file: `@param array $args {` for a tag, and the hash body + * indented below it. + * + * @param string $text Raw docblock text including the `/**` markers. + * @return list}> + */ + private function split_tags( string $text ): array { + $body = preg_replace( '#^\s*/\*\*#', '', $text, 1 ); + $body = preg_replace( '#\*/\s*$#', '', (string) $body, 1 ); + + $tags = array(); + $current = null; + + foreach ( preg_split( '#\R#', (string) $body ) ?: array() as $line ) { + $line = (string) preg_replace( '#^\s*\*[ ]?#', '', $line, 1 ); + + if ( preg_match( '#^@([a-zA-Z][a-zA-Z0-9_-]*)[ \t]*(.*)$#', $line, $matches ) === 1 ) { + $tags[] = array( + 'name' => strtolower( $matches[1] ), + 'header' => $matches[2], + 'body' => array(), + ); + $current = count( $tags ) - 1; + continue; + } + + if ( null !== $current ) { + $tags[ $current ]['body'][] = $line; + } + } + + return $tags; + } + + /** + * Reports whether the docblock already documents this tag for PHPStan. + * + * @param string $text Raw docblock text. + * @param string $tag Tag name, one of `param`, `return` or `var`. + * @param string|null $variable Variable the tag documents, without the `$`. + * @return bool + */ + private function has_phpstan_counterpart( string $text, string $tag, ?string $variable ): bool { + if ( 'return' === $tag ) { + return str_contains( $text, '@phpstan-return' ); + } + + if ( null === $variable ) { + return str_contains( $text, '@phpstan-' . $tag ); + } + + /* + * A hand-written shape often spans several lines, so the variable it + * documents can be far from the tag that opens it. Matching the tag and + * the variable without requiring them to be adjacent keeps a multi-line + * `@phpstan-param array{ ... } $args` recognized. + */ + return preg_match( + '#@phpstan-' . $tag . '\s.*?\$' . preg_quote( $variable, '#' ) . '\b#s', + $text + ) === 1; + } + + /** + * Parses the `@type` entries of one hash level. + * + * Nesting is tracked through the braces rather than through indentation, + * because core aligns a hash under the description column of the tag that + * opens it, and that column moves with the longest parameter name. + * + * @param list $lines Body lines of the tag, with docblock furniture removed. + * @param int $index Current position in `$lines`, advanced as entries are read. + * @return list}>|null + * Entries of this level, or null if the hash is malformed. + */ + private function parse_entries( array $lines, int &$index ): ?array { + $entries = array(); + $last = null; + $count = count( $lines ); + + while ( $index < $count ) { + $line = trim( $lines[ $index ] ); + ++$index; + + if ( '}' === $line ) { + return $entries; + } + + if ( str_starts_with( $line, '@type ' ) ) { + $entry = $this->parse_entry( substr( $line, 6 ) ); + if ( null === $entry ) { + return null; + } + + if ( $entry['opens'] ) { + $children = $this->parse_entries( $lines, $index ); + if ( null === $children || array() === $children ) { + return null; + } + $entry['children'] = $children; + } + + unset( $entry['opens'] ); + $entries[] = $entry; + $last = count( $entries ) - 1; + continue; + } + + // A tag other than `@type` inside a hash means the hash was never closed. + if ( str_starts_with( $line, '@' ) ) { + return null; + } + + if ( '' !== $line && null !== $last ) { + $entries[ $last ]['description'] .= ' ' . $line; + } + } + + return null; + } + + /** + * Parses one `@type` entry. + * + * @param string $rest Everything after `@type `. + * @return array{type: string, name: string, variadic: bool, description: string, children: list, opens: bool}|null + */ + private function parse_entry( string $rest ): ?array { + $rest = rtrim( $rest ); + $opens = false; + + if ( str_ends_with( $rest, '{' ) ) { + $opens = true; + $rest = rtrim( substr( $rest, 0, -1 ) ); + } + + $split = $this->split_type( $rest ); + if ( null === $split ) { + return null; + } + + list( $type, $remainder ) = $split; + + // Core keys are not always identifiers: `$mime-type` and `$post-trashed` are both documented. + if ( preg_match( '#^(\.\.\.)?\$([A-Za-z0-9_-]+)[ \t]*(.*)$#', $remainder, $matches ) !== 1 ) { + return null; + } + + return array( + 'type' => $type, + 'name' => $matches[2], + 'variadic' => '' !== $matches[1], + 'description' => $matches[3], + 'children' => array(), + 'opens' => $opens, + ); + } + + /** + * Splits a leading type off a string, keeping bracketed groups together. + * + * `array $deps` splits into `array` and + * `$deps`, rather than at the space inside the angle brackets. + * + * @param string $text Text beginning with a type. + * @return array{0: string, 1: string}|null Type and remainder, or null if there is no type. + */ + private function split_type( string $text ): ?array { + $text = ltrim( $text ); + $length = strlen( $text ); + $depth = 0; + $offset = $length; + + for ( $position = 0; $position < $length; $position++ ) { + $character = $text[ $position ]; + + if ( '<' === $character || '{' === $character || '(' === $character || '[' === $character ) { + ++$depth; + } elseif ( '>' === $character || '}' === $character || ')' === $character || ']' === $character ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + } elseif ( 0 === $depth && ( ' ' === $character || "\t" === $character ) ) { + $offset = $position; + break; + } + } + + if ( 0 !== $depth ) { + return null; + } + + $type = substr( $text, 0, $offset ); + if ( '' === $type ) { + return null; + } + + return array( $type, ltrim( substr( $text, $offset ) ) ); + } + + /** + * Replaces the bare `array` member of a type with a shape. + * + * @param string $declared Type as written in the docblock. + * @param list $entries Entries of the hash describing it. + * @param bool $for_param Whether the hash documents a `@param`. + * @return string|null The type with the shape substituted in, or null if it cannot be. + */ + private function substitute( string $declared, array $entries, bool $for_param ): ?string { + $members = $this->split_union( $declared ); + if ( null === $members ) { + return null; + } + + $target = null; + foreach ( $members as $position => $member ) { + if ( 'array' !== $member ) { + continue; + } + // Two bare members would leave it ambiguous which one the hash describes. + if ( null !== $target ) { + return null; + } + $target = $position; + } + + if ( null === $target ) { + return null; + } + + $shape = $this->resolve_container( $entries, $for_param ); + if ( null === $shape ) { + return null; + } + + $members[ $target ] = $shape; + + return implode( '|', $members ); + } + + /** + * Builds the shape for one hash level. + * + * @param list $entries Entries of this level. + * @param bool $for_param Whether the hash documents a `@param`. + * @return string|null + */ + private function resolve_container( array $entries, bool $for_param ): ?string { + /* + * A single `...$0` entry describes a repeated value rather than a key. + * The hash says nothing about the keys it repeats under, and core uses + * both numbered and named ones, so the keys stay `array-key`. + */ + if ( 1 === count( $entries ) && $entries[0]['variadic'] ) { + $inner = $this->resolve_entry_type( $entries[0], $for_param ); + + return null === $inner ? null : sprintf( 'array', $inner ); + } + + $members = array(); + foreach ( $entries as $entry ) { + if ( $entry['variadic'] ) { + return null; + } + + $type = $this->resolve_entry_type( $entry, $for_param ); + if ( null === $type ) { + return null; + } + + $members[] = sprintf( + '%s%s: %s', + $this->format_key( $entry['name'] ), + $this->is_optional( $entry, $for_param ) ? '?' : '', + $type + ); + } + + if ( array() === $members ) { + return null; + } + + /* + * A `@param` hash lists the keys core reads, not the only keys a caller + * may pass, so its shape stays open with a trailing `...`. Without it + * the shape would be sealed, and reading or testing for an undocumented + * key would be reported as an error at every call site that adds one. + */ + return sprintf( 'array{%s%s}', implode( ', ', $members ), $for_param ? ', ...' : '' ); + } + + /** + * Resolves the type of one entry, descending into its own hash if it has one. + * + * @param array{type: string, children: list} $entry Entry to resolve. + * @param bool $for_param Whether the hash documents a `@param`. + * @return string|null + */ + private function resolve_entry_type( array $entry, bool $for_param ): ?string { + if ( array() === $entry['children'] ) { + return $this->validate_type( $entry['type'] ); + } + + return $this->substitute( $entry['type'], $entry['children'], $for_param ); + } + + /** + * Reports whether a key is optional. + * + * @param array{name: string, description: string} $entry Entry to inspect. + * @param bool $for_param Whether the hash documents a `@param`. + * @return bool + */ + private function is_optional( array $entry, bool $for_param ): bool { + /* + * A `@return` hash describes a value core builds, so its keys are + * present unless the description says otherwise. `Default ...` is not + * that: a key documented with a default is still always set. + */ + if ( ! $for_param ) { + return preg_match( '#\bOptional\b#i', $entry['description'] ) === 1; + } + + // Numbered keys document positional arguments, which are always present. + if ( preg_match( '#^[0-9]+$#', $entry['name'] ) === 1 ) { + return false; + } + + return true; + } + + /** + * Formats a key for use in a shape, quoting it when it is not an identifier. + * + * @param string $name Key name, without the `$`. + * @return string + */ + private function format_key( string $name ): string { + if ( preg_match( '#^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+)$#', $name ) === 1 ) { + return $name; + } + + return "'" . str_replace( "'", "\\'", $name ) . "'"; + } + + /** + * Returns a type only if it is shaped like one. + * + * Guards against prose that has drifted into the type column of a `@type` + * tag, which would otherwise be emitted as a type PHPStan cannot parse. + * + * @param string $type Type as written in the docblock. + * @return string|null + */ + private function validate_type( string $type ): ?string { + return $this->split_union( $type ) === null ? null : $type; + } + + /** + * Splits a union type into its members, ignoring `|` inside brackets. + * + * @param string $type Type as written in the docblock. + * @return list|null Members, or null if the type is not well formed. + */ + private function split_union( string $type ): ?array { + $type = trim( $type ); + if ( preg_match( '#^[A-Za-z0-9_\\\\|<>{},:\'"\[\]\#\-\. ]+$#', $type ) !== 1 ) { + return null; + } + + $members = array(); + $member = ''; + $depth = 0; + $length = strlen( $type ); + + for ( $position = 0; $position < $length; $position++ ) { + $character = $type[ $position ]; + + if ( '<' === $character || '{' === $character || '(' === $character || '[' === $character ) { + ++$depth; + } elseif ( '>' === $character || '}' === $character || ')' === $character || ']' === $character ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + } elseif ( '|' === $character && 0 === $depth ) { + if ( '' === $member ) { + return null; + } + $members[] = $member; + $member = ''; + continue; + } + + $member .= $character; + } + + if ( 0 !== $depth || '' === $member ) { + return null; + } + + $members[] = $member; + + return $members; + } +} diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index edf96fefdc093..759c505c4c991 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -59,6 +59,39 @@ This directory also contains extensions that teach PHPStan conventions specific Core documents the globals a function uses with `@global Type $varname`. `GlobalDocBlockVisitor` bridges that convention to PHPStan's variable type resolution, so those globals are typed rather than `mixed` inside the function. +### Hash notation + +Core documents the contents of an array argument with a nested list of `@type` tags, [hash notation](https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays): + +```php +/** + * @param array $args { + * Optional. An array of arguments. + * + * @type string $post_type Post type. Default 'post'. + * @type int $post_author Post author ID. + * } + */ +``` + +PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed. `HashNotationVisitor` translates it into the array shape PHPStan understands, which for the example above is `array{post_type?: string, post_author?: int, ...}`, so the same documentation serves the reader and the analysis rather than each shape having to be written a second time as a `@phpstan-param`. + +A hash whose translation would be a guess is left alone, and the value keeps whatever type it has today. The visitor therefore only ever narrows a type, and never contradicts one: + +- A `@phpstan-param` or `@phpstan-return` written by hand always wins. Hash notation cannot express everything a type can — a function returning either of two shapes, for example — so a shape that has been tuned in the source is never overwritten by the derived one. +- The declared type has to name a bare `array`, on its own or as one member of a union such as `string|array`. A type that is already more specific than the hash, such as `array`, is left as written. +- The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a type and a `$name`. +- A parameter taken by reference is skipped, because PHPStan checks a by-reference argument in both directions, and a shape there would be a contract every caller's variable has to satisfy before the call rather than a description of what the function reads. + +Keys of a `@param` hash are optional, at every level, and the shape is left open with a trailing `...`, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and the shape is sealed, since they describe a value core itself builds — unless the description marks one `Optional.`, which the visitor honors. Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed`. + +Two kinds of hash are outside what the visitor covers today: + +- **`@var` hashes on properties.** A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does: that no subclass may widen the property, and that the declared default already has the shape. +- **`object` hashes**, such as the one on `get_taxonomy_labels()`. PHPStan's object shapes are structural, so a shape derived for a value core builds as a `stdClass` is no longer assignable to a property declared `stdClass`. Covering these needs the docblocks to name the class rather than `object`, so that the shape can be intersected with it. + +Hashes are also written on hook docblocks, where core documents `apply_filters()` and `do_action()`. Those are not attached to a function, so they are outside what this visitor sees, and the value a filter passes stays typed by [the hook extensions below](#hook-documentation). + ### Hook documentation The remaining extensions read the docblock documenting a hook where the hook is fired, which is where WordPress documents its hooks. They cover `apply_filters()`, `do_action()` and their `_deprecated` and `_ref_array` variants. diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index ab07051c9ad7a..f34f0d7021ddd 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -12,6 +12,14 @@ services: tags: - phpstan.parser.richParserNodeVisitor + # Bridges WordPress core's hash notation, the nested `@type` list documenting the + # contents of an array or object, to PHPStan's array and object shapes. + # See tests/phpstan/HashNotationVisitor.php. + - + class: WordPress\PHPStan\HashNotationVisitor + tags: + - phpstan.parser.richParserNodeVisitor + # Attaches the docblock documenting a hook to the hook's call, so that the return # type extension and the rules below can all read it. - @@ -150,6 +158,7 @@ parameters: - ../../src/wp-trackback.php - ../../src/xmlrpc.php - GlobalDocBlockVisitor.php + - HashNotationVisitor.php - HookDocsVisitor.php - HookDocBlock.php - ApplyFiltersDynamicFunctionReturnTypeExtension.php From f088f8d1c8f5264643f363b87dd8ac60b4a3b59b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:51:18 +0000 Subject: [PATCH 2/5] Docs: Correct the hashes PHPStan now reads as types. With hash notation translated into array shapes, the analysis checks these hashes against the code for the first time, and reports where the two disagree: - `WP_Http::processHeaders()` documents a `newheaders` key. The array it returns has `headers`. - `wp_edit_attachments_query()` documents `post_mime_types` and `avail_post_mime_types` keys. It returns the two values positionally, so they are `$0` and `$1`, as `wpdb::parse_db_host()` already writes them. - `WP_List_Table::get_views_links()` documents `url`, `label` and `current` as keys of `$link_data`. They are keys of each link in it, which its own `@return` line says: "Keys match the `$link_data` input array." Every caller passes a keyed array of links. - `wp_check_php_version()` sets `is_lower_than_future_minimum` on every array it returns, and both callers read it, but it is not documented. - `wpdb::parse_db_host()` documents the port as `string|null`, right below the line of code that casts it with `absint()` and the comment saying "Port cannot be a string; must be null or an integer." - `wp_xmlrpc_server::wp_editPage()` documents its content argument as a string. It is the content struct, which the method writes `post_type` into before passing it on. - `WP_Http::request()` documents `headers` as a `CaseInsensitiveDictionary`. A non-blocking request returns an empty array for it. - `wp_upload_bits()` documents `file`, `url` and `type` alongside `error`. Only `error` is set when the upload fails. Two returns cannot be described by a hash at all, because they are one shape or another rather than one shape with optional keys, so they gain a `@phpstan-return` beside the hash, as `wp_upload_dir()` and `_wp_handle_upload()` already have: `wp_font_dir()`, which returns what `wp_upload_dir()` returns, and `get_avatar_data()`, whose returned array also carries every argument passed to it, as its own description says. What remains are call sites passing an argument the documented shape does not accept, which is recorded in the baselines rather than resolved here. Each is a hash and a caller disagreeing about a key, and worth its own look. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- src/wp-admin/includes/class-wp-list-table.php | 12 +- src/wp-admin/includes/misc.php | 14 ++- src/wp-admin/includes/post.php | 6 +- src/wp-includes/class-wp-http.php | 15 +-- src/wp-includes/class-wp-xmlrpc-server.php | 2 +- src/wp-includes/class-wpdb.php | 2 +- src/wp-includes/fonts.php | 8 ++ src/wp-includes/functions.php | 6 +- src/wp-includes/link-template.php | 1 + tests/phpstan/baselines/argument.type.neon | 116 +++++++++++++++++- .../baselines/assign.propertyType.neon | 2 +- .../baselines/offsetAccess.notFound.neon | 2 +- 12 files changed, 156 insertions(+), 30 deletions(-) diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php index 5e6bcdb0d237c..17ec0c28f5c1f 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -427,11 +427,15 @@ public function search_box( $text, $input_id ) { * @since 6.1.0 * * @param array $link_data { - * An array of link data. + * An array of link data, keyed by view. * - * @type string $url The link URL. - * @type string $label The link label. - * @type bool $current Optional. Whether this is the currently selected view. + * @type array ...$0 { + * Data for a single view link. + * + * @type string $url The link URL. + * @type string $label The link label. + * @type bool $current Optional. Whether this is the currently selected view. + * } * } * @return string[] An array of link markup. Keys match the `$link_data` input array. */ diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index f021aedb8a5fb..c1ee93a2849e5 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -1570,12 +1570,14 @@ function _wp_privacy_settings_filter_draft_page_titles( $title, $page ) { * @return array|false { * Array of PHP version data. False on failure. * - * @type string $recommended_version The PHP version recommended by WordPress. - * @type string $minimum_version The minimum required PHP version. - * @type bool $is_supported Whether the PHP version is actively supported. - * @type bool $is_secure Whether the PHP version receives security updates. - * @type bool $is_acceptable Whether the PHP version is still acceptable or warnings - * should be shown and an update recommended. + * @type string $recommended_version The PHP version recommended by WordPress. + * @type string $minimum_version The minimum required PHP version. + * @type bool $is_supported Whether the PHP version is actively supported. + * @type bool $is_secure Whether the PHP version receives security updates. + * @type bool $is_acceptable Whether the PHP version is still acceptable or warnings + * should be shown and an update recommended. + * @type bool $is_lower_than_future_minimum Whether the PHP version is lower than the minimum PHP + * version WordPress will require in a future release. * } */ function wp_check_php_version() { diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index 39d267b623037..26f543520e447 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -1393,10 +1393,10 @@ function wp_edit_attachments_query_vars( $q = false ) { * @param array|false $q Optional. Array of query variables to use to build the query. * Defaults to the `$_GET` superglobal. * @return array { - * Array containing the post mime types and available post mime types. + * Array containing the post mime types and the available post mime types, in that order. * - * @type array[] $post_mime_types Post mime types. - * @type string[] $avail_post_mime_types Available post mime types. + * @type array[] $0 Post mime types. + * @type string[] $1 Available post mime types. * } */ function wp_edit_attachments_query( $q = false ) { diff --git a/src/wp-includes/class-wp-http.php b/src/wp-includes/class-wp-http.php index 323ec83aeca43..572c452b6a1fc 100644 --- a/src/wp-includes/class-wp-http.php +++ b/src/wp-includes/class-wp-http.php @@ -153,17 +153,18 @@ class WP_Http { * @return array|WP_Error { * Array of response data, or a WP_Error instance upon error. * - * @type \WpOrg\Requests\Utility\CaseInsensitiveDictionary $headers Response headers keyed by name. - * @type string $body Response body. - * @type array $response { + * @type \WpOrg\Requests\Utility\CaseInsensitiveDictionary|array $headers Response headers keyed by name. + * An empty array for a non-blocking request. + * @type string $body Response body. + * @type array $response { * Array of HTTP response data. * * @type int|false $code HTTP response status code. * @type string|false $message HTTP response message. * } - * @type WP_Http_Cookie[] $cookies Array of cookies set by the server. - * @type string|null $filename Optional. Filename of the response. - * @type WP_HTTP_Requests_Response|null $http_response Response object. + * @type WP_Http_Cookie[] $cookies Array of cookies set by the server. + * @type string|null $filename Optional. Filename of the response. + * @type WP_HTTP_Requests_Response|null $http_response Response object. * } */ public function request( $url, $args = array() ) { @@ -715,7 +716,7 @@ public static function processResponse( $response ) { // phpcs:ignore WordPress. * @type int $code The response status code. Default 0. * @type string $message The response message. Default empty. * } - * @type array $newheaders The processed header data as a multidimensional array. + * @type array $headers The processed header data as a multidimensional array. * @type WP_Http_Cookie[] $cookies If the original headers contain the 'Set-Cookie' key, * an array containing `WP_Http_Cookie` objects is returned. * } diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 1061dbd1831d2..11b3006506177 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -3190,7 +3190,7 @@ public function wp_deletePage( $args ) { * @type int $1 Page ID. * @type string $2 Username. * @type string $3 Password. - * @type string $4 Content. + * @type array $4 Content struct. * @type int $5 Publish flag. 0 for draft, 1 for publish. * } * @return array|IXR_Error diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index e9d7f986d5801..a676d395ee95c 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -2065,7 +2065,7 @@ public function db_connect( $allow_bail = true ) { * False if the host couldn't be parsed. * * @type string $0 Host name. - * @type string|null $1 Port. + * @type int|null $1 Port. * @type string|null $2 Socket. * @type bool $3 Whether it is an IPv6 address. * } diff --git a/src/wp-includes/fonts.php b/src/wp-includes/fonts.php index 1ffe9be96bb2a..572da46a2e1a6 100644 --- a/src/wp-includes/fonts.php +++ b/src/wp-includes/fonts.php @@ -142,6 +142,14 @@ function wp_get_font_dir() { * @type string $baseurl URL path without subdir. * @type string|false $error False or error message. * } + * @phpstan-return array{ + * path: non-empty-string, + * url: non-empty-string, + * subdir: non-empty-string, + * basedir: non-empty-string, + * baseurl: non-empty-string, + * } + * |array{ error: non-empty-string } */ function wp_font_dir( $create_dir = true ) { /* diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 924684499c324..ebb5898140faf 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2915,9 +2915,9 @@ function _wp_check_existing_file_names( $filename, $files ) { * @return array { * Information about the newly-uploaded file. * - * @type string $file Filename of the newly-uploaded file. - * @type string $url URL of the uploaded file. - * @type string $type File type. + * @type string $file Optional. Filename of the newly-uploaded file. Not set if there has been an error. + * @type string $url Optional. URL of the uploaded file. Not set if there has been an error. + * @type string $type Optional. File type. Not set if there has been an error. * @type string|false $error Error message, if there has been an error. * } */ diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 484a8c8f8591c..1002bd1705739 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4411,6 +4411,7 @@ function is_avatar_comment_type( $comment_type ) { * false or not set if none was found. * @type string|false $url The URL of the avatar that was found, or false. * } + * @phpstan-return array{ found_avatar: bool, url: string|false, ... } */ function get_avatar_data( $id_or_email, $args = null ) { $args = wp_parse_args( diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index da71134bcaf34..5f5b2cac0d229 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -93,6 +93,26 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/edit.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''edit_comment'', id\: non\-falsy\-string&numeric\-string, data\: string\|false, position\: ''\-1''\|int\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''link\-category'', id\: int, data\: non\-falsy\-string, position\: \-1\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php + - + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''term'', position\: int\<0, max\>, supplemental\: non\-empty\-array\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/ajax-actions.php - message: '#^Parameter \#1 \$attachment of function wp_get_attachment_id3_keys expects WP_Post, stdClass given\.$#' identifier: argument.type @@ -153,6 +173,16 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/class-wp-comments-list-table.php + - + message: '#^Parameter \#1 \$args of function get_bookmarks expects array\{orderby\?\: string, order\?\: string, limit\?\: int, category\?\: string, category_name\?\: string, hide_invisible\?\: bool\|int, show_updated\?\: bool\|int, include\?\: string, \.\.\., \.\.\.\}\|string, array\{hide_invisible\: 0, hide_empty\: 0, category\?\: int, search\?\: non\-falsy\-string, orderby\?\: non\-falsy\-string, order\?\: non\-falsy\-string\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-links-list-table.php + - + message: '#^Parameter \#2 \$args of function wp_admin_notice expects array\{type\?\: string, dismissible\?\: bool, id\?\: string, additional_classes\?\: array\, attributes\?\: array\, paragraph_wrap\?\: bool, \.\.\.\}, array\{type\: ''error'', additional_classes\: ''inline''\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-admin/includes/class-wp-ms-themes-list-table.php - message: '#^Parameter \#3 \$number of function _nx expects int, float given\.$#' identifier: argument.type @@ -369,7 +399,7 @@ parameters: count: 1 path: ../../../src/wp-admin/includes/template.php - - message: '#^Parameter \#1 \$update of method Language_Pack_Upgrader\:\:upgrade\(\) expects string\|false, stdClass given\.$#' + message: '#^Parameter \#1 \$update of method Language_Pack_Upgrader\:\:upgrade\(\) expects string\|false, object\{language\: string, version\: string, updated\: string, english_name\: string, native_name\: string, package\: string, iso\: array\, strings\: array\}&stdClass given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/translation-install.php @@ -509,7 +539,7 @@ parameters: count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php - - message: '#^Parameter \#3 \$args of function register_setting expects array, string given\.$#' + message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: callable\(\)\: mixed, show_in_rest\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php @@ -583,6 +613,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentynineteen/inc/helper-functions.php + - + message: '#^Parameter \#1 \$args of function comment_form expects array\{fields\?\: array\{author\?\: string, email\?\: string, url\?\: string, cookies\?\: string, \.\.\.\}, comment_field\?\: string, must_log_in\?\: string, logged_in_as\?\: string, comment_notes_before\?\: string, comment_notes_after\?\: string, action\?\: string, novalidate\?\: bool, \.\.\., \.\.\.\}, array\{title_reply\: null\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentynineteen/inc/template-tags.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -728,6 +763,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentytwenty/functions.php + - + message: '#^Parameter \#1 \$args of function wp_list_pages expects array\{child_of\?\: int, authors\?\: string, date_format\?\: string, depth\?\: int, echo\?\: bool, exclude\?\: string, include\?\: array, link_after\?\: string, \.\.\., \.\.\.\}\|string, array\{match_menu_classes\: true, show_sub_menu_icons\: true, title_li\: false, walker\: TwentyTwenty_Walker_Page\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwenty/header.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -738,6 +778,26 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentytwenty/template-parts/entry-author-bio.php + - + message: '#^Parameter \#1 \$args of function wp_nav_menu expects array\{menu\?\: int\|string\|WP_Term, menu_class\?\: string, menu_id\?\: string, container\?\: string, container_class\?\: string, container_id\?\: string, container_aria_label\?\: string, fallback_cb\?\: \(callable\(\)\: mixed\)\|false, \.\.\., \.\.\.\}, array\{theme_location\: ''social'', container\: '''', container_class\: '''', items_wrap\: ''%%3\$s'', menu_id\: '''', menu_class\: '''', depth\: 1, link_before\: ''\'', link_after\: ''\'', fallback_cb\: false\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-content/themes/twentytwentyone/footer.php - message: '#^Parameter \#1 \$author_id of function get_author_posts_url expects int, string given\.$#' identifier: argument.type @@ -813,11 +873,26 @@ parameters: identifier: argument.type count: 6 path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$args of function get_pages expects array\{child_of\?\: int, sort_order\?\: string, sort_column\?\: string, hierarchical\?\: bool, exclude\?\: array\, include\?\: array\, meta_key\?\: string, meta_value\?\: string, \.\.\., \.\.\.\}\|string, array\{number\: 1, hierarchical\: 0\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$args of method WP_Customize_Manager\:\:get_changeset_posts\(\) expects array\{posts_per_page\?\: int, author\?\: int, post_status\?\: string, exclude_restore_dismissed\?\: bool, \.\.\.\}, array\{post_status\: array\, exclude_restore_dismissed\: false, author\: ''any'', posts_per_page\: 1, order\: ''DESC'', orderby\: ''date''\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php - message: '#^Parameter \#1 \$month of function wp_checkdate expects int, \(string\|false\) given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\ given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-manager.php - message: '#^Parameter \#2 \$day of function wp_checkdate expects int, \(string\|false\) given\.$#' identifier: argument.type @@ -828,6 +903,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-customize-manager.php + - + message: '#^Parameter \#3 \$args of class WP_Customize_Filter_Setting constructor expects array\{type\?\: string, capability\?\: string, theme_supports\?\: array\\|string, default\?\: string, transport\?\: string, validate_callback\?\: callable\(\)\: mixed, sanitize_callback\?\: callable\(\)\: mixed, sanitize_js_callback\?\: callable\(\)\: mixed, \.\.\., \.\.\.\}, array\{transport\: ''postMessage'', type\: ''option'', default\: array\{\}, sanitize_callback\: array\{\$this\(WP_Customize_Nav_Menus\), ''sanitize_nav_menus…''\}\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-customize-nav-menus.php - message: '#^Parameter \#2 \$parent_query of method WP_Date_Query\:\:get_sql_for_clause\(\) expects array, string given\.$#' identifier: argument.type @@ -853,6 +933,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-duotone.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\ given\.$#' + identifier: argument.type + count: 2 + path: ../../../src/wp-includes/class-wp-embed.php - message: '#^Parameter \#3 \$priority of function _wp_filter_build_unique_id expects int, false given\.$#' identifier: argument.type @@ -948,6 +1033,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-xmlrpc-server.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\{post_author\: int, post_date\: int\|string, post_date_gmt\: int\|string, post_content\: string, post_title\: string, post_category\: array\\|string, post_status\: ''draft''\|''publish''\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/class-wp-xmlrpc-server.php - message: '#^Parameter \#1 \$term_id of method wp_xmlrpc_server\:\:get_term_custom_fields\(\) expects int, string given\.$#' identifier: argument.type @@ -1243,6 +1333,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/nav-menu.php + - + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\{menu_order\: mixed, ping_status\: 0, post_content\: mixed, post_excerpt\: mixed, post_parent\: int\|string\|WP_Error\|null, post_title\: mixed, post_type\: ''nav_menu_item'', post_date\?\: non\-falsy\-string, \.\.\.\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/nav-menu.php - message: '#^Parameter \#2 \$value of function setcookie expects string, int\<1, max\> given\.$#' identifier: argument.type @@ -1283,6 +1378,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/pluggable.php + - + message: '#^Parameter \#1 \$args of function get_pages expects array\{child_of\?\: int, sort_order\?\: string, sort_column\?\: string, hierarchical\?\: bool, exclude\?\: array\, include\?\: array\, meta_key\?\: string, meta_value\?\: string, \.\.\., \.\.\.\}\|string, non\-empty\-array given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/post-template.php - message: '#^Parameter \#1 \$attachment of function is_attachment expects array\\|int\|string, WP_Post given\.$#' identifier: argument.type @@ -1391,7 +1491,12 @@ parameters: - message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array given\.$#' identifier: argument.type - count: 2 + count: 1 + path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php + - + message: '#^Parameter \#1 \$data_object of method WP_REST_Controller\:\:update_additional_fields_for_object\(\) expects object, array\ given\.$#' + identifier: argument.type + count: 1 path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php - message: '#^Parameter \#1 \$comment_id of function get_comment_type expects int\|WP_Comment, string given\.$#' @@ -1583,6 +1688,11 @@ parameters: identifier: argument.type count: 2 path: ../../../src/wp-includes/user.php + - + message: '#^Parameter \#1 \$args of function register_sidebar expects array\{name\?\: string, id\?\: string, description\?\: string, class\?\: string, before_widget\?\: string, after_widget\?\: string, before_title\?\: string, after_title\?\: string, \.\.\., \.\.\.\}\|string, non\-empty\-array\\|string\> given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/widgets.php - message: '#^Parameter \#3 \$control_callback of function wp_register_widget_control expects callable\(\)\: mixed, '''' given\.$#' identifier: argument.type diff --git a/tests/phpstan/baselines/assign.propertyType.neon b/tests/phpstan/baselines/assign.propertyType.neon index f53f802b7d1da..b2f4452c16a03 100644 --- a/tests/phpstan/baselines/assign.propertyType.neon +++ b/tests/phpstan/baselines/assign.propertyType.neon @@ -154,7 +154,7 @@ parameters: count: 1 path: ../../../src/wp-includes/taxonomy.php - - message: '#^Static property WP_Widget_Media\:\:\$l10n_defaults \(array\\) does not accept array\\.$#' + message: '#^Static property WP_Widget_Media\:\:\$l10n_defaults \(array\\) does not accept array\\|string\>\.$#' identifier: assign.propertyType count: 1 path: ../../../src/wp-includes/widgets/class-wp-widget-media.php diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon index a5e2eb0698cc8..4470319b88805 100644 --- a/tests/phpstan/baselines/offsetAccess.notFound.neon +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -19,7 +19,7 @@ parameters: ignoreErrors: - - message: '#^Offset float does not exist on list\.$#' + message: '#^Offset float does not exist on list\\.$#' identifier: offsetAccess.notFound count: 1 path: ../../../src/wp-admin/includes/class-wp-site-health.php From 01605e7b29d03b61182550b78c19335c21874836 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:40:09 +0000 Subject: [PATCH 3/5] Build/Test Tools: Derive shapes from hashes on objects too. An object shape in PHPStan is structural, so one derived from a bare `@return object { ... }` describes the members and nothing else. That made it useless where core actually puts those values: `WP_Taxonomy::$labels` and `WP_Post_Type::$cap` are declared `stdClass`, which a bare `object{...}` is not, so assigning one was an error and the hashes had to be skipped. Naming the class in the docblock resolves it. A hash on a class produces an intersection, `stdClass&object{...}`, which is still the class and now also carries the members, so it is assignable to a property declared `stdClass` and reads of those members are typed. The three returns that build one with a cast say `stdClass` rather than `object` to match what they return: `get_taxonomy_labels()`, `get_post_type_capabilities()` and `wp_get_scheduled_event()`. An intersection inside a union is parenthesized, so `wp_get_scheduled_event()` reads `(stdClass&object{...})|false`. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- src/wp-includes/cron.php | 2 +- src/wp-includes/post.php | 2 +- src/wp-includes/taxonomy.php | 2 +- tests/phpstan/HashNotationVisitor.php | 102 +++++++++++++++--- tests/phpstan/README.md | 11 +- tests/phpstan/baselines/argument.type.neon | 2 +- .../baselines/offsetAccess.notFound.neon | 2 +- 7 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php index 3fb6a29cb8dc7..faa5b0fd6681e 100644 --- a/src/wp-includes/cron.php +++ b/src/wp-includes/cron.php @@ -765,7 +765,7 @@ function wp_unschedule_hook( $hook, $wp_error = false ) { * Default empty array. * @param int|null $timestamp Optional. Unix timestamp (UTC) of the event. If not specified, the next scheduled event * is returned. Default null. - * @return object|false { + * @return stdClass|false { * The event object. False if the event does not exist. * * @type string $hook Action hook to execute when the event is run. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2db73e9a20476..8b407bd4311bb 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -2011,7 +2011,7 @@ function unregister_post_type( $post_type ) { * @see map_meta_cap() * * @param object $args Post type registration arguments. - * @return object { + * @return stdClass { * Object with all the capabilities as member variables. * * @type string $edit_post Capability to edit a post. diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 29317f0a8bf9b..e5aa953275c9d 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -648,7 +648,7 @@ function unregister_taxonomy( $taxonomy ) { * @since 6.6.0 Added the `template_name` label. * * @param WP_Taxonomy $tax Taxonomy object. - * @return object { + * @return stdClass { * Taxonomy labels object. The first default value is for non-hierarchical taxonomies * (like tags) and the second one is for hierarchical taxonomies (like categories). * diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index f175d55cb63f9..4ec6789a70de3 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -1,7 +1,7 @@ `, is left as written. An - * `object` hash is left alone as well: PHPStan's object shapes are - * structural, so one derived for a value core builds as a `stdClass` would - * no longer be assignable to a property declared `stdClass`. + * - The declared type must name something a shape can be put on: a bare `array` + * or `object`, or a class, on its own or as one member of a union such as + * `string|array`. A type that is already more specific than the hash, such as + * `array`, is left as written. A shape derived for a + * named class is intersected with it, as `stdClass&object{...}`, because an + * object shape is structural on its own and one derived from a bare `object` + * would not be assignable to a property declared `stdClass`. * - The hash has to be well formed: every `{` closed by a `}` on a line of its * own, and every `@type` carrying a type and a `$name`. Anything else, and * the whole tag is skipped. @@ -443,10 +444,10 @@ private function substitute( string $declared, array $entries, bool $for_param ) $target = null; foreach ( $members as $position => $member ) { - if ( 'array' !== $member ) { + if ( ! $this->is_shapeable( $member ) ) { continue; } - // Two bare members would leave it ambiguous which one the hash describes. + // Two shapeable members would leave it ambiguous which one the hash describes. if ( null !== $target ) { return null; } @@ -457,30 +458,101 @@ private function substitute( string $declared, array $entries, bool $for_param ) return null; } - $shape = $this->resolve_container( $entries, $for_param ); + $member = $members[ $target ]; + $shape = $this->resolve_container( $entries, $for_param, 'array' === $member ); if ( null === $shape ) { return null; } + /* + * An object shape is structural, so one derived for a value core builds as a + * `stdClass` would no longer be assignable to a property declared `stdClass`. + * Naming the class in the docblock keeps both: the value stays that class, and + * its members are typed by the shape intersected with it. + */ + if ( 'array' !== $member && 'object' !== $member ) { + $shape = $member . '&' . $shape; + + // An intersection inside a union needs parentheses to parse. + if ( count( $members ) > 1 ) { + $shape = '(' . $shape . ')'; + } + } + $members[ $target ] = $shape; return implode( '|', $members ); } + /** + * Reports whether a member of a union type can carry a shape. + * + * `array` and `object` take one directly. A class name takes one through an + * intersection, so the value keeps the class it is documented as, which is + * what makes a `stdClass` hash usable where the class is expected. + * + * @param string $member One member of a union type. + * @return bool + */ + private function is_shapeable( string $member ): bool { + if ( 'array' === $member || 'object' === $member ) { + return true; + } + + // A name PHPDoc gives a meaning of its own is not a class, whatever its shape. + $keywords = array( + 'bool', + 'boolean', + 'callable', + 'double', + 'false', + 'float', + 'int', + 'integer', + 'iterable', + 'list', + 'mixed', + 'never', + 'null', + 'number', + 'numeric', + 'parent', + 'resource', + 'scalar', + 'self', + 'static', + 'string', + 'this', + 'true', + 'void', + ); + + if ( in_array( strtolower( $member ), $keywords, true ) ) { + return false; + } + + return preg_match( '#^\\\\?[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*$#', $member ) === 1; + } + /** * Builds the shape for one hash level. * * @param list $entries Entries of this level. * @param bool $for_param Whether the hash documents a `@param`. + * @param bool $is_array Whether the hash describes an array rather than an object. * @return string|null */ - private function resolve_container( array $entries, bool $for_param ): ?string { + private function resolve_container( array $entries, bool $for_param, bool $is_array ): ?string { /* * A single `...$0` entry describes a repeated value rather than a key. * The hash says nothing about the keys it repeats under, and core uses * both numbered and named ones, so the keys stay `array-key`. */ if ( 1 === count( $entries ) && $entries[0]['variadic'] ) { + if ( ! $is_array ) { + return null; + } + $inner = $this->resolve_entry_type( $entries[0], $for_param ); return null === $inner ? null : sprintf( 'array', $inner ); @@ -515,6 +587,10 @@ private function resolve_container( array $entries, bool $for_param ): ?string { * the shape would be sealed, and reading or testing for an undocumented * key would be reported as an error at every call site that adds one. */ + if ( ! $is_array ) { + return sprintf( 'object{%s}', implode( ', ', $members ) ); + } + return sprintf( 'array{%s%s}', implode( ', ', $members ), $for_param ? ', ...' : '' ); } diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 759c505c4c991..6288acf674612 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -61,7 +61,7 @@ Core documents the globals a function uses with `@global Type $varname`. `Global ### Hash notation -Core documents the contents of an array argument with a nested list of `@type` tags, [hash notation](https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays): +Core documents the contents of an array or object with a nested list of `@type` tags, [hash notation](https://developer.wordpress.org/coding-standards/inline-documentation-standards/php/#1-1-parameters-that-are-arrays): ```php /** @@ -74,21 +74,20 @@ Core documents the contents of an array argument with a nested list of `@type` t */ ``` -PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed. `HashNotationVisitor` translates it into the array shape PHPStan understands, which for the example above is `array{post_type?: string, post_author?: int, ...}`, so the same documentation serves the reader and the analysis rather than each shape having to be written a second time as a `@phpstan-param`. +PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed. `HashNotationVisitor` translates it into the array or object shape PHPStan understands, which for the example above is `array{post_type?: string, post_author?: int, ...}`, so the same documentation serves the reader and the analysis rather than each shape having to be written a second time as a `@phpstan-param`. A hash whose translation would be a guess is left alone, and the value keeps whatever type it has today. The visitor therefore only ever narrows a type, and never contradicts one: - A `@phpstan-param` or `@phpstan-return` written by hand always wins. Hash notation cannot express everything a type can — a function returning either of two shapes, for example — so a shape that has been tuned in the source is never overwritten by the derived one. -- The declared type has to name a bare `array`, on its own or as one member of a union such as `string|array`. A type that is already more specific than the hash, such as `array`, is left as written. +- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array`. A type that is already more specific than the hash, such as `array`, is left as written. - The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a type and a `$name`. - A parameter taken by reference is skipped, because PHPStan checks a by-reference argument in both directions, and a shape there would be a contract every caller's variable has to satisfy before the call rather than a description of what the function reads. Keys of a `@param` hash are optional, at every level, and the shape is left open with a trailing `...`, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and the shape is sealed, since they describe a value core itself builds — unless the description marks one `Optional.`, which the visitor honors. Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed`. -Two kinds of hash are outside what the visitor covers today: +A hash on a class rather than on `array` or `object` produces an intersection, `stdClass&object{...}`, rather than a bare object shape. PHPStan's object shapes are structural, so a bare `object{...}` derived for a value core builds as a `stdClass` would no longer be assignable to a property declared `stdClass`. Intersecting keeps both: the value stays the class it is documented as, and its members are typed. This is why the returns that build one, such as `get_taxonomy_labels()`, document `stdClass` rather than `object`. -- **`@var` hashes on properties.** A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does: that no subclass may widen the property, and that the declared default already has the shape. -- **`object` hashes**, such as the one on `get_taxonomy_labels()`. PHPStan's object shapes are structural, so a shape derived for a value core builds as a `stdClass` is no longer assignable to a property declared `stdClass`. Covering these needs the docblocks to name the class rather than `object`, so that the shape can be intersected with it. +One kind of hash is outside what the visitor covers today: **a `@var` hash on a property**. A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does — that no subclass may widen the property, and that the declared default already has the shape. Hashes are also written on hook docblocks, where core documents `apply_filters()` and `do_action()`. Those are not attached to a function, so they are outside what this visitor sees, and the value a filter passes stays typed by [the hook extensions below](#hook-documentation). diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 5f5b2cac0d229..ecf075d0efea0 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -94,7 +94,7 @@ parameters: count: 1 path: ../../../src/wp-admin/edit.php - - message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: mixed, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' + message: '#^Parameter \#1 \$args of method WP_Ajax_Response\:\:add\(\) expects array\{what\?\: string, action\?\: string\|false, id\?\: int\|WP_Error, old_id\?\: int\|false, position\?\: string, data\?\: string\|WP_Error, supplemental\?\: array, \.\.\.\}\|string, array\{what\: ''comment'', id\: numeric\-string, data\: string\|false, position\: ''\-1''\|int, supplemental\: array\{in_moderation\: int, i18n_comments_text\: string, i18n_moderation_text\: string, parent_approved\: numeric\-string, parent_post_id\: numeric\-string\}\|array\{in_moderation\: int, i18n_comments_text\: string, i18n_moderation_text\: string\}\} given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-admin/includes/ajax-actions.php diff --git a/tests/phpstan/baselines/offsetAccess.notFound.neon b/tests/phpstan/baselines/offsetAccess.notFound.neon index 4470319b88805..a5e2eb0698cc8 100644 --- a/tests/phpstan/baselines/offsetAccess.notFound.neon +++ b/tests/phpstan/baselines/offsetAccess.notFound.neon @@ -19,7 +19,7 @@ parameters: ignoreErrors: - - message: '#^Offset float does not exist on list\\.$#' + message: '#^Offset float does not exist on list\.$#' identifier: offsetAccess.notFound count: 1 path: ../../../src/wp-admin/includes/class-wp-site-health.php From 282eb833a4d01de4d79e90fb670fabb2ef49afb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:48:32 +0000 Subject: [PATCH 4/5] Build/Test Tools: Attach the rewritten docblock without a position. The docblock the visitor builds is longer than the one in the file, so the original start line and file position no longer describe where its text lives. `GlobalDocBlockVisitor` leaves both off for the same reason; this one was passing them through. No change to what is derived locally, and the analysis stays green. Co-authored-by: Pascal Birchler Co-Authored-By: Claude --- tests/phpstan/HashNotationVisitor.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/phpstan/HashNotationVisitor.php b/tests/phpstan/HashNotationVisitor.php index 4ec6789a70de3..39b980f87ef2e 100644 --- a/tests/phpstan/HashNotationVisitor.php +++ b/tests/phpstan/HashNotationVisitor.php @@ -127,7 +127,13 @@ public function enterNode( Node $node ): ?Node { return null; } - $node->setDocComment( new Doc( $merged, $doc->getStartLine(), $doc->getStartFilePos() ) ); + /* + * The rewritten docblock is longer than the one in the file, so it carries no + * position. Keeping the original start would point at a span of the source that + * no longer holds this text, which is the same reason GlobalDocBlockVisitor + * leaves it off. + */ + $node->setDocComment( new Doc( $merged ) ); return null; } From 7feda5613dc89b3e44861c07487bd1a20d9517d4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sat, 22 Aug 2026 12:45:54 +0000 Subject: [PATCH 5/5] Build/Test Tools: Key the PHPStan cache on the analysis configuration. The cache CI keeps for PHPStan holds more than the analysis results. PHPStan also stores what it read out of each source file there, the docblocks and signatures it found, keyed by that file's contents and nothing else. A parser node visitor changes what reading a file yields without changing the file, so a cache written before `HashNotationVisitor` existed answers with the docblocks core had before it, and no shape is derived from any hash. That is what the run on this branch was reporting. It restored the cache trunk's run on the base commit wrote, so every file this branch does not touch came back from that cache without a shape, the baselines written against those shapes matched nothing, and PHPStan reported them under `ignore.unmatched` and `ignore.count`. Reproduced by analysing the base commit with an empty `.cache` and then analysing this branch on top of the cache that left behind: the same reports, on the same lines, down to `register_setting` printing trunk's `expects array, string given`. With `.cache` cleared the branch is green, which is why it looked green locally. Keying the cache on `phpstan.neon.dist` and the sources in `tests/phpstan` keeps a run from restoring a cache that predates either. The baselines are left out of the key: they only decide which reported errors are ignored, PHPStan invalidates the results cache on a configuration change by itself, and including them would discard the whole cache every time one is regenerated. Nothing keys the cache for a local run, so `tests/phpstan/README.md` says to clear it by hand after changing anything in that directory. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Are1pyfSoWBPabAmc4vPP1 --- .../reusable-phpstan-static-analysis-v1.yml | 17 ++++++++++++++--- tests/phpstan/README.md | 12 ++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index 26a14ba8d890f..10a5ef3b384df 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -84,13 +84,24 @@ jobs: - name: Build WordPress run: npm run build:dev + # The directory holds more than the analysis results. PHPStan also stores what it read out of + # each source file there, the docblocks and signatures it found, keyed by that file's contents + # and nothing else. The extensions in `tests/phpstan` change what reading a file yields without + # changing the file: a parser node visitor rewrites a docblock in the syntax tree, and the bytes + # on disk stay as they were. A cache written before one of them changed therefore answers with + # what the old code saw, and the analysis silently runs against types no longer derived. + # + # Keying on the configuration and the extensions keeps a run from restoring a cache that + # predates either. The baselines are left out of the key: they only decide which reported errors + # are ignored, PHPStan invalidates the results cache on a configuration change by itself, and + # including them would discard the whole cache every time one is regenerated. - name: Cache PHP Static Analysis scan cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .cache # This is defined in the base.neon file. - key: "phpstan-result-cache-${{ github.run_id }}" + key: "phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" restore-keys: | - phpstan-result-cache- + phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}- - name: Run PHP static analysis tests id: phpstan @@ -193,7 +204,7 @@ jobs: if: ${{ !cancelled() }} with: path: .cache - key: "phpstan-result-cache-${{ github.run_id }}" + key: "phpstan-result-cache-${{ hashFiles('phpstan.neon.dist', 'tests/phpstan/*.neon', 'tests/phpstan/*.php') }}-${{ github.run_id }}" - name: Ensure version-controlled files are not modified or deleted run: git diff --exit-code diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 6288acf674612..44e6a630f9e80 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -196,4 +196,16 @@ PHPStan can be resource-intensive, especially on large codebases like WordPress. PHPStan caches analysis results to speed up subsequent runs. You can see information about the results cache by running `analyse` with the `-vv` or `-vvv` flag. +### Clear the cache after changing anything in this directory + +The `.cache` directory holds more than the results. PHPStan also stores what it read out of each source file there, the docblocks and signatures it found, keyed by that file's contents and nothing else. The sources in this directory change what reading a file yields without changing the file: `HashNotationVisitor` rewrites a docblock in the syntax tree, and the bytes on disk stay as they were. + +A cache written before one of them changed therefore answers with what the old code saw. The run does not fail or warn; it reports against types that are no longer derived, so a visitor can look as though it does nothing, or as though it does less than it does. So clear the cache by hand after editing anything here: + +```bash +rm -rf .cache +``` + +The results cache alone is not the problem. PHPStan invalidates that itself when the configuration changes, and says which part of it no longer matches under `-vv`. What survives is the per-file reflection, which it has no way to know is stale. CI keys its cache on these files for the same reason; see `.github/workflows/reusable-phpstan-static-analysis-v1.yml`. + Sometimes, due to the lack of type information in legacy code, PHPStan may still struggle to analyze certain parts of the codebase. In such cases, you can use the `--debug` flag to disable caching and see which files are causing issues.